Update collections and attachment uploads

This commit is contained in:
rucki
2026-09-03 13:59:02 +02:00
parent 28e17a4b21
commit d97244de40
9 changed files with 337 additions and 24 deletions
@@ -0,0 +1,18 @@
# Generated by Django 5.2.17 on 2026-08-31 08:40
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0019_remove_collectionsection_unique_collection_item_and_more'),
]
operations = [
migrations.AlterField(
model_name='collection',
name='mode',
field=models.CharField(choices=[('manual', 'Manual collection'), ('tags', 'Tag collection'), ('tutorial', 'Tutorial'), ('chapter', 'Chapter')], default='manual', max_length=10),
),
]
+8
View File
@@ -199,6 +199,14 @@ class Collection(models.Model):
class Mode(models.TextChoices): class Mode(models.TextChoices):
MANUAL = 'manual', _('Manual collection') MANUAL = 'manual', _('Manual collection')
TAGS = 'tags', _('Tag collection') TAGS = 'tags', _('Tag collection')
TUTORIAL = 'tutorial', _('Tutorial')
CHAPTER = 'chapter', _('Chapter')
STRUCTURED_MODES = {Mode.MANUAL, Mode.TUTORIAL, Mode.CHAPTER}
@property
def is_structured(self):
return self.mode in self.STRUCTURED_MODES
owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='collections') owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='collections')
team = models.ForeignKey(Team, on_delete=models.SET_NULL, blank=True, null=True, related_name='collections') team = models.ForeignKey(Team, on_delete=models.SET_NULL, blank=True, null=True, related_name='collections')
+2 -3
View File
@@ -1,15 +1,14 @@
{% extends 'core/base.html' %} {% extends 'core/base.html' %}
{% load i18n markdown_extras %} {% load i18n markdown_extras %}
{% block content %} {% block content %}
<div class="d-md-none alert alert-info">{% trans "Collections are currently available on desktop only." %}</div> <article class="collection-document">
<article class="d-none d-md-block collection-document">
<header class="mb-5"> <header class="mb-5">
<div class="d-flex justify-content-between align-items-start gap-3"> <div class="d-flex justify-content-between align-items-start gap-3">
<div> <div>
<a class="btn btn-sm btn-outline-secondary mb-3" href="{% if user.is_authenticated %}{% url 'collection_list' %}{% else %}/{% endif %}">← {% if user.is_authenticated %}{% trans "Collections" %}{% else %}Start{% endif %}</a> <a class="btn btn-sm btn-outline-secondary mb-3" href="{% if user.is_authenticated %}{% url 'collection_list' %}{% else %}/{% endif %}">← {% if user.is_authenticated %}{% trans "Collections" %}{% else %}Start{% endif %}</a>
<h1 class="display-5 mb-3">{{ collection.title }}</h1> <h1 class="display-5 mb-3">{{ collection.title }}</h1>
<div class="d-flex flex-wrap gap-2 align-items-center"> <div class="d-flex flex-wrap gap-2 align-items-center">
{% if collection.mode == 'tags' %}<span class="badge text-bg-info">{% trans "Tag collection" %}</span>{% endif %} {% if collection.mode != 'manual' %}<span class="badge text-bg-info">{{ collection.get_mode_display }}</span>{% endif %}
{% if collection.team %}<span class="badge text-bg-warning">{{ collection.team.name }}</span>{% else %}<span class="badge text-bg-light">{{ collection.get_visibility_display }}</span>{% endif %} {% if collection.team %}<span class="badge text-bg-warning">{{ collection.team.name }}</span>{% else %}<span class="badge text-bg-light">{{ collection.get_visibility_display }}</span>{% endif %}
{% for tag in collection.filter_tags.all %}<span class="badge rounded-pill text-bg-light">#{{ tag.name }}</span>{% endfor %} {% for tag in collection.filter_tags.all %}<span class="badge rounded-pill text-bg-light">#{{ tag.name }}</span>{% endfor %}
</div> </div>
+10 -3
View File
@@ -1,16 +1,23 @@
{% extends 'core/base.html' %} {% extends 'core/base.html' %}
{% load i18n %} {% load i18n %}
{% block content %} {% block content %}
<div class="d-md-none alert alert-info">{% trans "Collections are currently available on desktop only." %}</div> <div>
<div class="d-none d-md-block">
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex justify-content-between align-items-center mb-4">
<div><h1 class="h3 mb-1">{% trans "Collections" %}</h1><p class="text-muted mb-0">{% trans "Combine existing notes into ordered documents." %}</p></div> <div><h1 class="h3 mb-1">{% trans "Collections" %}</h1><p class="text-muted mb-0">{% trans "Combine existing notes into ordered documents." %}</p></div>
<a class="btn btn-dark" href="{% url 'collection_create' %}">{% trans "New collection" %}</a> <a class="btn btn-dark" href="{% url 'collection_create' %}">{% trans "New collection" %}</a>
</div> </div>
<div class="d-flex flex-wrap gap-2 mb-4">
<a class="btn btn-sm {% if active_mode == 'listed' %}btn-dark{% else %}btn-outline-secondary{% endif %}" href="{% url 'collection_list' %}">{% trans "All" %}</a>
<a class="btn btn-sm {% if active_mode == 'manual' %}btn-dark{% else %}btn-outline-secondary{% endif %}" href="?mode=manual">{% trans "Manual collection" %}</a>
<a class="btn btn-sm {% if active_mode == 'tutorial' %}btn-dark{% else %}btn-outline-secondary{% endif %}" href="?mode=tutorial">{% trans "Tutorial" %}</a>
<a class="btn btn-sm {% if active_mode == 'tags' %}btn-dark{% else %}btn-outline-secondary{% endif %}" href="?mode=tags">{% trans "Tag collection" %}</a>
<a class="btn btn-sm {% if active_mode == 'chapter' %}btn-dark{% else %}btn-outline-secondary{% endif %}" href="?mode=chapter">{% trans "Chapters" %}</a>
</div>
{% if orphan_chapters %}<div class="alert alert-warning"><div class="fw-semibold mb-1">{% trans "Orphan chapters" %}</div><div class="small">{% trans "These chapters are not used by any tutorial yet:" %} {% for chapter in orphan_chapters %}<a href="{{ chapter.get_absolute_url }}">{{ chapter.title }}</a>{% if not forloop.last %}, {% endif %}{% endfor %}</div></div>{% endif %}
<div class="row g-3"> <div class="row g-3">
{% for entry in entries %}{% with collection=entry.collection %} {% for entry in entries %}{% with collection=entry.collection %}
<div class="col-md-6"><a class="card item-card h-100 text-decoration-none text-dark" href="{{ collection.get_absolute_url }}"><div class="card-body"> <div class="col-md-6"><a class="card item-card h-100 text-decoration-none text-dark" href="{{ collection.get_absolute_url }}"><div class="card-body">
<div class="d-flex justify-content-between gap-2"><h2 class="h5 mb-1">{{ collection.title }}</h2><div class="d-flex gap-1">{% if collection.mode == 'tags' %}<span class="badge text-bg-info">{% trans "Tag collection" %}</span>{% endif %}{% if collection.team %}<span class="badge text-bg-warning">{{ collection.team.name }}</span>{% else %}<span class="badge text-bg-light">{{ collection.get_visibility_display }}</span>{% endif %}</div></div> <div class="d-flex justify-content-between gap-2"><h2 class="h5 mb-1">{{ collection.title }}</h2><div class="d-flex gap-1">{% if collection.mode != 'manual' %}<span class="badge text-bg-info">{{ collection.get_mode_display }}</span>{% endif %}{% if collection.team %}<span class="badge text-bg-warning">{{ collection.team.name }}</span>{% else %}<span class="badge text-bg-light">{{ collection.get_visibility_display }}</span>{% endif %}</div></div>
{% if collection.description %}<p class="text-muted mt-2 mb-2">{{ collection.description|truncatechars:140 }}</p>{% endif %} {% if collection.description %}<p class="text-muted mt-2 mb-2">{{ collection.description|truncatechars:140 }}</p>{% endif %}
{% if collection.mode == 'tags' %}<div class="mb-2">{% for tag in collection.filter_tags.all %}<span class="badge rounded-pill text-bg-light">#{{ tag.name }}</span> {% endfor %}</div>{% endif %} {% if collection.mode == 'tags' %}<div class="mb-2">{% for tag in collection.filter_tags.all %}<span class="badge rounded-pill text-bg-light">#{{ tag.name }}</span> {% endfor %}</div>{% endif %}
<div class="small text-muted">{{ entry.section_count }} {% trans "sections" %} · {{ collection.updated_at|date:'d.m.Y H:i' }}</div> <div class="small text-muted">{{ entry.section_count }} {% trans "sections" %} · {{ collection.updated_at|date:'d.m.Y H:i' }}</div>
+34 -2
View File
@@ -8,6 +8,7 @@
<a class="btn btn-sm btn-outline-secondary mb-3" href="{{ collection.get_absolute_url }}" hx-boost="false">← {% trans "View collection" %}</a> <a class="btn btn-sm btn-outline-secondary mb-3" href="{{ collection.get_absolute_url }}" hx-boost="false">← {% trans "View collection" %}</a>
<h1 class="display-6 mb-2">{{ collection.title }}</h1> <h1 class="display-6 mb-2">{{ collection.title }}</h1>
<div class="d-flex flex-wrap gap-2 align-items-center"> <div class="d-flex flex-wrap gap-2 align-items-center">
{% if collection.mode != 'manual' %}<span class="badge text-bg-info">{{ collection.get_mode_display }}</span>{% endif %}
{% if collection.team %}<span class="badge text-bg-warning">{{ collection.team.name }}</span>{% else %}<span class="badge text-bg-light">{{ collection.get_visibility_display }}</span>{% endif %} {% if collection.team %}<span class="badge text-bg-warning">{{ collection.team.name }}</span>{% else %}<span class="badge text-bg-light">{{ collection.get_visibility_display }}</span>{% endif %}
{% for tag in collection.filter_tags.all %}<span class="badge rounded-pill text-bg-light">#{{ tag.name }}</span>{% endfor %} {% for tag in collection.filter_tags.all %}<span class="badge rounded-pill text-bg-light">#{{ tag.name }}</span>{% endfor %}
</div> </div>
@@ -35,9 +36,9 @@
</div> </div>
{% endif %} {% endif %}
<div id="collection-sections" class="vstack gap-4 mb-5"> <div id="collection-sections" class="vstack gap-4 mb-5" data-reorder-url="{% url 'collection_manage' collection.id %}">
{% for section in sections %} {% for section in sections %}
<section id="section-{{ section.id }}" class="card item-card"><div class="card-body"> <section id="section-{{ section.id }}" class="card item-card" draggable="true" data-section-id="{{ section.id }}" data-position="{{ forloop.counter0 }}"><div class="card-body">
<div class="d-flex justify-content-between align-items-start gap-3 mb-3"> <div class="d-flex justify-content-between align-items-start gap-3 mb-3">
<h2 class="h3 mb-0">{% if section.item %}{{ section.title|default:section.item.content|markdown_title|truncatechars:100 }}{% else %}{{ section.title|default:section.sub_collection.title|truncatechars:100 }}{% endif %}</h2> <h2 class="h3 mb-0">{% if section.item %}{{ section.title|default:section.item.content|markdown_title|truncatechars:100 }}{% else %}{{ section.title|default:section.sub_collection.title|truncatechars:100 }}{% endif %}</h2>
<div class="d-flex gap-2 align-items-center">{% if section.item %}<a class="btn btn-sm btn-outline-secondary" href="{% url 'item_editor' section.item.id %}?next={% url 'collection_manage' collection.id %}" hx-boost="false">{% trans "Edit note" %}</a><a class="small text-muted" href="{{ section.item.get_absolute_url }}">#{{ section.item.id }}</a>{% else %}<a class="btn btn-sm btn-outline-secondary" href="{% url 'collection_manage' section.sub_collection.id %}" hx-boost="false">{% trans "Edit collection" %}</a><a class="small text-muted" href="{{ section.sub_collection.get_absolute_url }}">#{{ section.sub_collection.id }}</a>{% endif %}</div> <div class="d-flex gap-2 align-items-center">{% if section.item %}<a class="btn btn-sm btn-outline-secondary" href="{% url 'item_editor' section.item.id %}?next={% url 'collection_manage' collection.id %}" hx-boost="false">{% trans "Edit note" %}</a><a class="small text-muted" href="{{ section.item.get_absolute_url }}">#{{ section.item.id }}</a>{% else %}<a class="btn btn-sm btn-outline-secondary" href="{% url 'collection_manage' section.sub_collection.id %}" hx-boost="false">{% trans "Edit collection" %}</a><a class="small text-muted" href="{{ section.sub_collection.get_absolute_url }}">#{{ section.sub_collection.id }}</a>{% endif %}</div>
@@ -45,6 +46,8 @@
{% if section.item %}<div class="content">{{ section.item|item_markdown }}</div> {% if section.item %}<div class="content">{{ section.item|item_markdown }}</div>
{% if section.item.comment %}<div class="content text-muted border-start ps-3 mt-3">{{ section.item.comment|markdown }}</div>{% endif %}{% else %}<div class="text-muted">{% trans "Nested collection" %}: {{ section.sub_collection.title }}</div>{% endif %} {% if section.item.comment %}<div class="content text-muted border-start ps-3 mt-3">{{ section.item.comment|markdown }}</div>{% endif %}{% else %}<div class="text-muted">{% trans "Nested collection" %}: {{ section.sub_collection.title }}</div>{% endif %}
{% if can_edit %}<div class="border-top mt-4 pt-3 d-flex align-items-center gap-2"> {% if can_edit %}<div class="border-top mt-4 pt-3 d-flex align-items-center gap-2">
<span class="btn btn-sm btn-outline-secondary" title="{% trans 'Drag to reorder' %}">↕</span>
<form method="post" class="d-none move-form">{% csrf_token %}<input type="hidden" name="action" value="move_to"><input type="hidden" name="section_id" value="{{ section.id }}"><input type="hidden" name="position" value="{{ forloop.counter0 }}"></form>
<form method="post" class="d-flex gap-2 flex-grow-1" hx-on::before-request="window.collectionFocus='section-{{ section.id }}'">{% csrf_token %}<input type="hidden" name="action" value="title"><input type="hidden" name="section_id" value="{{ section.id }}"><input class="form-control form-control-sm" name="title" value="{{ section.title }}" placeholder="{% trans 'Optional section title' %}"><button class="btn btn-sm btn-outline-secondary">{% trans "Save title" %}</button></form> <form method="post" class="d-flex gap-2 flex-grow-1" hx-on::before-request="window.collectionFocus='section-{{ section.id }}'">{% csrf_token %}<input type="hidden" name="action" value="title"><input type="hidden" name="section_id" value="{{ section.id }}"><input class="form-control form-control-sm" name="title" value="{{ section.title }}" placeholder="{% trans 'Optional section title' %}"><button class="btn btn-sm btn-outline-secondary">{% trans "Save title" %}</button></form>
<form method="post" hx-on::before-request="window.collectionFocus='section-{{ section.id }}'">{% csrf_token %}<input type="hidden" name="action" value="up"><input type="hidden" name="section_id" value="{{ section.id }}"><button class="btn btn-sm btn-outline-secondary" title="{% trans 'Move up' %}">↑</button></form> <form method="post" hx-on::before-request="window.collectionFocus='section-{{ section.id }}'">{% csrf_token %}<input type="hidden" name="action" value="up"><input type="hidden" name="section_id" value="{{ section.id }}"><button class="btn btn-sm btn-outline-secondary" title="{% trans 'Move up' %}">↑</button></form>
<form method="post" hx-on::before-request="window.collectionFocus='section-{{ section.id }}'">{% csrf_token %}<input type="hidden" name="action" value="down"><input type="hidden" name="section_id" value="{{ section.id }}"><button class="btn btn-sm btn-outline-secondary" title="{% trans 'Move down' %}">↓</button></form> <form method="post" hx-on::before-request="window.collectionFocus='section-{{ section.id }}'">{% csrf_token %}<input type="hidden" name="action" value="down"><input type="hidden" name="section_id" value="{{ section.id }}"><button class="btn btn-sm btn-outline-secondary" title="{% trans 'Move down' %}">↓</button></form>
@@ -57,6 +60,8 @@
{% if can_edit %} {% if can_edit %}
<div class="card item-card"><div class="card-body"> <div class="card item-card"><div class="card-body">
<h2 class="h4">{% trans "Add existing note or collection" %}</h2> <h2 class="h4">{% trans "Add existing note or collection" %}</h2>
<div class="mb-4"><a class="btn btn-dark" href="{% url 'new_item' %}?kind=note&collection={{ collection.id }}&next={% url 'collection_manage' collection.id %}" hx-boost="false">{% trans "Create note" %}</a></div>
<form method="post" class="input-group mb-4" hx-boost="false">{% csrf_token %}<input type="hidden" name="action" value="create_chapter"><input class="form-control" name="title" placeholder="{% trans 'New chapter title' %}"><button class="btn btn-outline-dark">{% trans "Create chapter" %}</button></form>
<form method="get" class="input-group mb-3"><input class="form-control" name="q" value="{{ q }}" placeholder="{% trans 'Search notes and collections' %}"><button class="btn btn-outline-secondary">{% trans "Search" %}</button></form> <form method="get" class="input-group mb-3"><input class="form-control" name="q" value="{{ q }}" placeholder="{% trans 'Search notes and collections' %}"><button class="btn btn-outline-secondary">{% trans "Search" %}</button></form>
<h3 class="h6">{% trans "Notes" %}</h3> <h3 class="h6">{% trans "Notes" %}</h3>
<div class="vstack gap-2 mb-4"> <div class="vstack gap-2 mb-4">
@@ -69,4 +74,31 @@
</div></div> </div></div>
{% endif %} {% endif %}
</div> </div>
<script>
(function(){
const list = document.getElementById('collection-sections');
if(!list) return;
let dragged = null;
list.addEventListener('dragstart', event => {
dragged = event.target.closest('[data-section-id]');
if(dragged) event.dataTransfer.effectAllowed = 'move';
});
list.addEventListener('dragover', event => {
if(!dragged) return;
const target = event.target.closest('[data-section-id]');
if(!target || target === dragged) return;
event.preventDefault();
const after = event.clientY > target.getBoundingClientRect().top + target.offsetHeight / 2;
target.parentNode.insertBefore(dragged, after ? target.nextSibling : target);
});
list.addEventListener('drop', event => {
if(!dragged) return;
event.preventDefault();
const sections = [...list.querySelectorAll('[data-section-id]')];
const form = dragged.querySelector('.move-form');
form.querySelector('[name=position]').value = sections.indexOf(dragged);
form.submit();
});
})();
</script>
{% endblock %} {% endblock %}
+2 -1
View File
@@ -4,9 +4,10 @@
<form class="card item-card shadow-sm position-relative" method="post" enctype="multipart/form-data" x-data="quickComposer([{% for tag in tags %}'{{ tag.name|escapejs }}'{% if not forloop.last %},{% endif %}{% endfor %}])"> <form class="card item-card shadow-sm position-relative" method="post" enctype="multipart/form-data" x-data="quickComposer([{% for tag in tags %}'{{ tag.name|escapejs }}'{% if not forloop.last %},{% endif %}{% endfor %}])">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="next" value="{{ next_url }}"> <input type="hidden" name="next" value="{{ next_url }}">
{% if collection %}<input type="hidden" name="collection" value="{{ collection.id }}">{% endif %}
<div class="card-body vstack gap-3"> <div class="card-body vstack gap-3">
<div class="d-flex justify-content-between align-items-center gap-2 sticky-top bg-white py-2" style="z-index:5"> <div class="d-flex justify-content-between align-items-center gap-2 sticky-top bg-white py-2" style="z-index:5">
<h1 class="h5 m-0">{% if requested_kind == 'todo' %}{% trans "Create task" %}{% elif requested_kind == 'link' %}{% trans "Create link" %}{% elif requested_kind == 'journal' %}{% trans "Create journal entry" %}{% else %}{% trans "Create note" %}{% endif %}</h1> <h1 class="h5 m-0">{% if collection %}{% blocktrans with title=collection.title %}Create note for {{ title }}{% endblocktrans %}{% elif requested_kind == 'todo' %}{% trans "Create task" %}{% elif requested_kind == 'link' %}{% trans "Create link" %}{% elif requested_kind == 'journal' %}{% trans "Create journal entry" %}{% else %}{% trans "Create note" %}{% endif %}</h1>
<div class="d-flex gap-2"><button class="btn btn-success px-3 py-2" title="{% trans 'Save' %}">✓</button><a class="btn btn-danger px-3 py-2" href="{{ next_url }}" title="{% trans 'Cancel' %}">×</a></div> <div class="d-flex gap-2"><button class="btn btn-success px-3 py-2" title="{% trans 'Save' %}">✓</button><a class="btn btn-danger px-3 py-2" href="{{ next_url }}" title="{% trans 'Cancel' %}">×</a></div>
</div> </div>
<div class="mobile-tag-picker"> <div class="mobile-tag-picker">
+146 -1
View File
@@ -1,5 +1,10 @@
import shutil
import tempfile
from unittest.mock import patch
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.test import TestCase from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase, override_settings
from django.urls import reverse from django.urls import reverse
from .models import ApiKey, Collection, CollectionSection, Item, Tag, Team, TeamMembership, UserPreference from .models import ApiKey, Collection, CollectionSection, Item, Tag, Team, TeamMembership, UserPreference
@@ -103,6 +108,20 @@ class SlashMenuContextTests(TestCase):
self.assertIn('/public', content) self.assertIn('/public', content)
self.assertIn('/private', content) self.assertIn('/private', content)
@patch('core.views.fetch_page_title', return_value='Example page')
def test_url_on_first_line_becomes_link_with_title_and_extra_text(self, _fetch_title):
kind, _, content, url = parse_quick_content('https://example.com/page\nEigener Text #tag')
self.assertEqual(kind, Item.Kind.LINK)
self.assertEqual(url, 'https://example.com/page')
self.assertEqual(content, 'Example page\nEigener Text #tag')
@patch('core.views.fetch_page_title', return_value='Example page')
def test_explicit_link_command_uses_first_line_url_title(self, _fetch_title):
kind, _, content, url = parse_quick_content('/link https://example.com/page\n#tag')
self.assertEqual(kind, Item.Kind.LINK)
self.assertEqual(url, 'https://example.com/page')
self.assertEqual(content, 'Example page\n#tag')
class MarkdownHeadingTests(TestCase): class MarkdownHeadingTests(TestCase):
def test_tag_at_line_start_is_not_a_heading(self): def test_tag_at_line_start_is_not_a_heading(self):
@@ -171,6 +190,31 @@ class NavigationAndLayoutTests(TestCase):
self.assertLess(toolbar_position, file_position) self.assertLess(toolbar_position, file_position)
class AttachmentEditTests(TestCase):
def setUp(self):
self.media_root = tempfile.mkdtemp()
self.override = override_settings(MEDIA_ROOT=self.media_root)
self.override.enable()
self.user = get_user_model().objects.create_user(username='attachment-editor', password='secret')
self.client.force_login(self.user)
def tearDown(self):
self.override.disable()
shutil.rmtree(self.media_root, ignore_errors=True)
def test_uploaded_file_is_visible_after_first_inline_edit_save(self):
item = Item.objects.create(owner=self.user, content='Original')
uploaded = SimpleUploadedFile('hello.txt', b'hello', content_type='text/plain')
response = self.client.post(reverse('edit_item', args=[item.id]), {
'content': 'Original', 'comment': '', 'visibility': Item.Visibility.PRIVATE, 'url': '', 'files': uploaded,
}, HTTP_HX_REQUEST='true')
self.assertEqual(response.status_code, 200)
self.assertEqual(item.attachments.count(), 1)
self.assertContains(response, 'hello.txt')
class TeamItemEditTests(TestCase): class TeamItemEditTests(TestCase):
def setUp(self): def setUp(self):
self.user = get_user_model().objects.create_user(username='team-editor', password='secret') self.user = get_user_model().objects.create_user(username='team-editor', password='secret')
@@ -301,6 +345,107 @@ class CollectionTests(TestCase):
self.assertEqual(list(form.fields['team'].queryset), [team]) self.assertEqual(list(form.fields['team'].queryset), [team])
self.assertEqual(list(form.fields['filter_tags'].queryset.values_list('name', flat=True)), ['ordinary']) self.assertEqual(list(form.fields['filter_tags'].queryset.values_list('name', flat=True)), ['ordinary'])
def test_tutorial_is_listed_but_chapter_is_hidden_from_collection_overview(self):
tutorial = Collection.objects.create(owner=self.user, title='my2dos Tutorial', mode=Collection.Mode.TUTORIAL)
chapter = Collection.objects.create(owner=self.user, title='my2dos Typen', mode=Collection.Mode.CHAPTER)
response = self.client.get(reverse('collection_list'))
self.assertContains(response, tutorial.title)
self.assertNotIn(chapter, [entry['collection'] for entry in response.context['entries']])
def test_collection_list_can_filter_collection_types(self):
Collection.objects.create(owner=self.user, title='Manual', mode=Collection.Mode.MANUAL)
Collection.objects.create(owner=self.user, title='Tutorial', mode=Collection.Mode.TUTORIAL)
Collection.objects.create(owner=self.user, title='Tagged', mode=Collection.Mode.TAGS)
Collection.objects.create(owner=self.user, title='Chapter', mode=Collection.Mode.CHAPTER)
response = self.client.get(f'{reverse("collection_list")}?mode=chapter')
self.assertEqual([entry['collection'].title for entry in response.context['entries']], ['Chapter'])
def test_orphan_chapters_are_shown_as_warning(self):
Collection.objects.create(owner=self.user, title='Lonely chapter', mode=Collection.Mode.CHAPTER)
response = self.client.get(reverse('collection_list'))
self.assertContains(response, 'Verwaiste Kapitel')
self.assertContains(response, 'Lonely chapter')
def test_tutorial_can_include_chapter_collection(self):
tutorial = Collection.objects.create(owner=self.user, title='my2dos Tutorial', mode=Collection.Mode.TUTORIAL)
chapter = Collection.objects.create(owner=self.user, title='my2dos Typen', mode=Collection.Mode.CHAPTER)
note = Item.objects.create(owner=self.user, kind=Item.Kind.NOTE, content='## Todos')
CollectionSection.objects.create(collection=chapter, item=note, position=0)
manage_url = reverse('collection_manage', args=[tutorial.id])
response = self.client.post(manage_url, {'action': 'add_collection', 'collection_id': chapter.id})
self.assertRedirects(response, manage_url)
self.assertTrue(tutorial.sections.filter(sub_collection=chapter).exists())
response = self.client.get(tutorial.get_absolute_url())
self.assertContains(response, '<h2>my2dos Typen</h2>', html=True)
self.assertContains(response, '<h3>Todos</h3>', html=True)
def test_collection_overview_is_available_on_mobile(self):
response = self.client.get(reverse('collection_list'))
self.assertNotContains(response, 'Collections are currently available on desktop only.')
self.assertContains(response, 'Sammlungen')
def test_collection_manage_links_to_note_editor_with_return_target(self):
collection = Collection.objects.create(owner=self.user, title='Reading view')
manage_url = reverse('collection_manage', args=[collection.id])
response = self.client.get(manage_url)
self.assertContains(response, f'{reverse("new_item")}?kind=note&collection={collection.id}&next={manage_url}')
def test_create_note_for_collection_in_editor_adds_it_and_returns(self):
collection = Collection.objects.create(owner=self.user, title='Reading view')
manage_url = reverse('collection_manage', args=[collection.id])
response = self.client.post(f'{reverse("new_item")}?kind=note&collection={collection.id}&next={manage_url}', {
'collection': collection.id, 'next': manage_url, 'content': '## Fresh note #new',
})
note = Item.objects.get(content='## Fresh note #new')
self.assertRedirects(response, manage_url)
self.assertTrue(collection.sections.filter(item=note).exists())
self.assertEqual(note.visibility, collection.visibility)
self.assertEqual(list(note.tags.values_list('name', flat=True)), ['new'])
def test_create_chapter_from_tutorial_manage_adds_and_opens_it(self):
tutorial = Collection.objects.create(owner=self.user, title='my2dos Tutorial', mode=Collection.Mode.TUTORIAL)
response = self.client.post(reverse('collection_manage', args=[tutorial.id]), {'action': 'create_chapter', 'title': 'Tags'})
chapter = Collection.objects.get(title='Tags')
self.assertEqual(chapter.mode, Collection.Mode.CHAPTER)
self.assertTrue(tutorial.sections.filter(sub_collection=chapter).exists())
self.assertRedirects(response, reverse('collection_manage', args=[chapter.id]))
def test_collection_sections_can_be_reordered_by_position(self):
collection = Collection.objects.create(owner=self.user, title='Reading view')
first = Item.objects.create(owner=self.user, kind=Item.Kind.NOTE, content='First')
second = Item.objects.create(owner=self.user, kind=Item.Kind.NOTE, content='Second')
first_section = CollectionSection.objects.create(collection=collection, item=first, position=0)
second_section = CollectionSection.objects.create(collection=collection, item=second, position=1)
response = self.client.post(reverse('collection_manage', args=[collection.id]), {
'action': 'move_to', 'section_id': second_section.id, 'position': 0,
})
self.assertRedirects(response, reverse('collection_manage', args=[collection.id]))
self.assertEqual(list(collection.sections.order_by('position').values_list('id', flat=True)), [second_section.id, first_section.id])
def test_collection_detail_is_available_on_mobile(self):
collection = Collection.objects.create(owner=self.user, title='Mobile reading')
response = self.client.get(collection.get_absolute_url())
self.assertNotContains(response, 'Collections are currently available on desktop only.')
self.assertContains(response, 'collection-document')
def test_tag_collection_is_dynamic_and_sorted_by_creation(self): def test_tag_collection_is_dynamic_and_sorted_by_creation(self):
first = Item.objects.create(owner=self.user, kind=Item.Kind.NOTE, content='First #guide #basics') first = Item.objects.create(owner=self.user, kind=Item.Kind.NOTE, content='First #guide #basics')
first.sync_metadata() first.sync_metadata()
+76 -14
View File
@@ -26,6 +26,7 @@ TYPE_COMMAND_RE = re.compile(r'^/(todo|note|link|journal)\b', re.I)
VISIBILITY_COMMAND_RE = re.compile(r'/(public|private)\b', re.I) VISIBILITY_COMMAND_RE = re.compile(r'/(public|private)\b', re.I)
URL_RE = re.compile(r'https?://\S+') URL_RE = re.compile(r'https?://\S+')
ONLY_URL_RE = re.compile(r'^https?://\S+$') ONLY_URL_RE = re.compile(r'^https?://\S+$')
FIRST_LINE_URL_RE = re.compile(r'^(https?://\S+)[ \t]*(?:\r?\n|$)')
TITLE_RE = re.compile(r'<title[^>]*>(.*?)</title>', re.I | re.S) TITLE_RE = re.compile(r'<title[^>]*>(.*?)</title>', re.I | re.S)
DATE_COMMAND_RE = re.compile(r'/datum\s+(\d{1,2}\.\d{1,2}\.(?:\d{2}|\d{4}))(?:\s+(\d{1,2}:\d{2}))?\b', re.I) DATE_COMMAND_RE = re.compile(r'/datum\s+(\d{1,2}\.\d{1,2}\.(?:\d{2}|\d{4}))(?:\s+(\d{1,2}:\d{2}))?\b', re.I)
RELATIVE_DATE_COMMAND_RE = re.compile(r'/(morgen|übermorgen|uebermorgen|nächstewoche|naechstewoche)\b', re.I) RELATIVE_DATE_COMMAND_RE = re.compile(r'/(morgen|übermorgen|uebermorgen|nächstewoche|naechstewoche)\b', re.I)
@@ -88,20 +89,25 @@ def parse_quick_content(raw, default_kind=Item.Kind.NOTE):
visibility_spans = [(match.start() - offset, match.end() - offset) for match in visibility_matches if match.start() >= offset] visibility_spans = [(match.start() - offset, match.end() - offset) for match in visibility_matches if match.start() >= offset]
content = remove_spans(content, visibility_spans) content = remove_spans(content, visibility_spans)
only_url = bool(ONLY_URL_RE.match(content)) only_url = bool(ONLY_URL_RE.match(content))
first_line_url_match = FIRST_LINE_URL_RE.match(content)
if type_command == 'todo': if type_command == 'todo':
kind = Item.Kind.TODO kind = Item.Kind.TODO
elif type_command == 'journal': elif type_command == 'journal':
kind = Item.Kind.JOURNAL kind = Item.Kind.JOURNAL
elif type_command == 'link' or only_url: elif type_command == 'link' or only_url or first_line_url_match:
kind = Item.Kind.LINK kind = Item.Kind.LINK
elif type_command == 'note': elif type_command == 'note':
kind = Item.Kind.NOTE kind = Item.Kind.NOTE
else: else:
kind = default_kind if default_kind in Item.Kind.values else Item.Kind.NOTE kind = default_kind if default_kind in Item.Kind.values else Item.Kind.NOTE
visibility = Item.Visibility.PUBLIC if 'public' in visibility_commands else Item.Visibility.PRIVATE visibility = Item.Visibility.PUBLIC if 'public' in visibility_commands else Item.Visibility.PRIVATE
url_match = URL_RE.search(content) url_match = first_line_url_match or URL_RE.search(content)
url = url_match.group(0) if kind == Item.Kind.LINK and url_match else '' url = url_match.group(1) if kind == Item.Kind.LINK and first_line_url_match else (url_match.group(0) if kind == Item.Kind.LINK and url_match else '')
if only_url: if first_line_url_match:
title = fetch_page_title(url) or url
extra_content = content[first_line_url_match.end():].strip()
content = f'{title}\n{extra_content}' if extra_content else title
elif only_url:
content = fetch_page_title(url) or url content = fetch_page_title(url) or url
return kind, visibility, content, url return kind, visibility, content, url
@@ -153,9 +159,13 @@ def parse_due_at_from_post(post):
def attach_files_and_replace_tokens(item, files): def attach_files_and_replace_tokens(item, files):
replacements = {} replacements = {}
attached = False
for file in files: for file in files:
attachment = Attachment.objects.create(item=item, file=file) attachment = Attachment.objects.create(item=item, file=file)
attached = True
replacements[f'![[paste:{file.name}]]'] = f'![[file:{attachment.id}]]' replacements[f'![[paste:{file.name}]]'] = f'![[file:{attachment.id}]]'
if attached and hasattr(item, '_prefetched_objects_cache'):
item._prefetched_objects_cache.pop('attachments', None)
if replacements: if replacements:
content = item.content content = item.content
comment = item.comment comment = item.comment
@@ -350,6 +360,13 @@ def new_item(request):
next_url = request.GET.get('next') or request.POST.get('next') or reverse('index') next_url = request.GET.get('next') or request.POST.get('next') or reverse('index')
if next_url == 'index': if next_url == 'index':
next_url = reverse('index') next_url = reverse('index')
collection = None
collection_id = request.GET.get('collection') or request.POST.get('collection')
if collection_id:
collection = get_object_or_404(visible_collections(request.user).filter(mode__in=Collection.STRUCTURED_MODES), pk=collection_id)
if not user_can_edit_collection(request.user, collection):
return JsonResponse({'error': 'forbidden'}, status=403)
requested_kind = Item.Kind.NOTE
if request.method == 'POST': if request.method == 'POST':
form = QuickItemForm(request.POST, request.FILES) form = QuickItemForm(request.POST, request.FILES)
if form.is_valid(): if form.is_valid():
@@ -362,9 +379,12 @@ def new_item(request):
attach_files_and_replace_tokens(item, request.FILES.getlist('files')) attach_files_and_replace_tokens(item, request.FILES.getlist('files'))
item.sync_metadata() item.sync_metadata()
apply_team_from_tags(item, request.user, force_private=bool(re.search(r'/private\b', raw, re.I))) apply_team_from_tags(item, request.user, force_private=bool(re.search(r'/private\b', raw, re.I)))
if collection:
item = adapt_item_for_collection(item, collection)
add_collection_section(collection, item)
return redirect(next_url) return redirect(next_url)
return render(request, 'core/new_item.html', { return render(request, 'core/new_item.html', {
'form': QuickItemForm(), 'tags': Tag.objects.all(), 'requested_kind': requested_kind, 'next_url': next_url, 'form': QuickItemForm(), 'tags': Tag.objects.all(), 'requested_kind': requested_kind, 'next_url': next_url, 'collection': collection,
}) })
@@ -715,6 +735,10 @@ def editable_collection_notes(user):
).filter(Q(owner=user) | Q(team_id__in=user_team_ids(user))).distinct() ).filter(Q(owner=user) | Q(team_id__in=user_team_ids(user))).distinct()
def collection_is_structured_mode(mode):
return mode in Collection.STRUCTURED_MODES
def collection_detail_context(request, collection, pending_item=None): def collection_detail_context(request, collection, pending_item=None):
can_edit = user_can_edit_collection(request.user, collection) can_edit = user_can_edit_collection(request.user, collection)
q = request.GET.get('q', '').strip() q = request.GET.get('q', '').strip()
@@ -724,7 +748,7 @@ def collection_detail_context(request, collection, pending_item=None):
used_item_ids = set(collection.sections.filter(item__isnull=False).values_list('item_id', flat=True)) used_item_ids = set(collection.sections.filter(item__isnull=False).values_list('item_id', flat=True))
used_collection_ids = set(collection.sections.filter(sub_collection__isnull=False).values_list('sub_collection_id', flat=True)) used_collection_ids = set(collection.sections.filter(sub_collection__isnull=False).values_list('sub_collection_id', flat=True))
candidates = editable_collection_notes(request.user).annotate(collection_use_count=Count('collection_sections', distinct=True)) candidates = editable_collection_notes(request.user).annotate(collection_use_count=Count('collection_sections', distinct=True))
collection_candidates = visible_collections(request.user).filter(mode=Collection.Mode.MANUAL).exclude(pk=collection.pk).annotate(collection_use_count=Count('containing_sections', distinct=True)) collection_candidates = visible_collections(request.user).filter(mode__in=Collection.STRUCTURED_MODES).exclude(pk=collection.pk).annotate(collection_use_count=Count('containing_sections', distinct=True))
collection_candidates = [candidate for candidate in collection_candidates if collection_subcollection_is_compatible(collection, candidate) and not collection_contains_collection(candidate, collection)] collection_candidates = [candidate for candidate in collection_candidates if collection_subcollection_is_compatible(collection, candidate) and not collection_contains_collection(candidate, collection)]
if q: if q:
candidates = candidates.filter(Q(content__icontains=q) | Q(tags__name__icontains=q)).distinct() candidates = candidates.filter(Q(content__icontains=q) | Q(tags__name__icontains=q)).distinct()
@@ -750,11 +774,25 @@ def collection_detail_context(request, collection, pending_item=None):
@login_required @login_required
def collection_list(request): def collection_list(request):
active_mode = request.GET.get('mode', 'listed')
collections = visible_collections(request.user).prefetch_related('filter_tags')
if active_mode == 'manual':
collections = collections.filter(mode=Collection.Mode.MANUAL)
elif active_mode == 'tutorial':
collections = collections.filter(mode=Collection.Mode.TUTORIAL)
elif active_mode == 'tags':
collections = collections.filter(mode=Collection.Mode.TAGS)
elif active_mode == 'chapter':
collections = collections.filter(mode=Collection.Mode.CHAPTER)
else:
active_mode = 'listed'
collections = collections.exclude(mode=Collection.Mode.CHAPTER)
entries = [] entries = []
for collection in visible_collections(request.user).prefetch_related('filter_tags'): for collection in collections:
count = collection_tag_items(collection).count() if collection.mode == Collection.Mode.TAGS else collection.sections.count() count = collection_tag_items(collection).count() if collection.mode == Collection.Mode.TAGS else collection.sections.count()
entries.append({'collection': collection, 'section_count': count}) entries.append({'collection': collection, 'section_count': count})
return render(request, 'core/collection_list.html', {'entries': entries}) orphan_chapters = visible_collections(request.user).filter(mode=Collection.Mode.CHAPTER, containing_sections__isnull=True) if active_mode != 'chapter' else Collection.objects.none()
return render(request, 'core/collection_list.html', {'entries': entries, 'active_mode': active_mode, 'orphan_chapters': orphan_chapters})
@login_required @login_required
@@ -772,7 +810,7 @@ def collection_create(request):
collection.team = team collection.team = team
collection.save() collection.save()
form.save_m2m() form.save_m2m()
if collection.mode == Collection.Mode.MANUAL: if collection.mode != Collection.Mode.TAGS:
collection.filter_tags.clear() collection.filter_tags.clear()
messages.success(request, _('Collection created.')) messages.success(request, _('Collection created.'))
return redirect(collection) return redirect(collection)
@@ -791,8 +829,8 @@ def collection_edit(request, pk):
if form.is_valid(): if form.is_valid():
visibility, team = collection_target_from_form(form, request.user) visibility, team = collection_target_from_form(form, request.user)
target_mode = form.cleaned_data['mode'] target_mode = form.cleaned_data['mode']
has_manual_sections = collection.mode == Collection.Mode.MANUAL and collection.sections.exists() has_manual_sections = collection_is_structured_mode(collection.mode) and collection.sections.exists()
incompatible = target_mode == Collection.Mode.MANUAL and any( incompatible = collection_is_structured_mode(target_mode) and any(
not collection_scope_allows_item( not collection_scope_allows_item(
visibility, team.id if team else None, collection.owner_id, visibility, team.id if team else None, collection.owner_id,
section.item.visibility, section.item.team_id, section.item.owner_id, section.item.visibility, section.item.team_id, section.item.owner_id,
@@ -814,7 +852,7 @@ def collection_edit(request, pk):
collection.team = team collection.team = team
collection.save() collection.save()
form.save_m2m() form.save_m2m()
if collection.mode == Collection.Mode.MANUAL: if collection.mode != Collection.Mode.TAGS:
collection.filter_tags.clear() collection.filter_tags.clear()
messages.success(request, _('Collection saved.')) messages.success(request, _('Collection saved.'))
return redirect(collection) return redirect(collection)
@@ -827,7 +865,7 @@ def collection_detail(request, pk):
collection = get_object_or_404(visible_collections(request.user), pk=pk) collection = get_object_or_404(visible_collections(request.user), pk=pk)
return render(request, 'core/collection_detail.html', { return render(request, 'core/collection_detail.html', {
'collection': collection, 'collection': collection,
'sections': collection.sections.select_related('item', 'sub_collection').prefetch_related('item__tags', 'item__attachments', 'sub_collection__sections__item', 'sub_collection__sections__sub_collection') if collection.mode == Collection.Mode.MANUAL else [], 'sections': collection.sections.select_related('item', 'sub_collection').prefetch_related('item__tags', 'item__attachments', 'sub_collection__sections__item', 'sub_collection__sections__sub_collection') if collection.is_structured else [],
'tag_items': collection_tag_items(collection) if collection.mode == Collection.Mode.TAGS else [], 'tag_items': collection_tag_items(collection) if collection.mode == Collection.Mode.TAGS else [],
'can_edit': user_can_edit_collection(request.user, collection), 'can_edit': user_can_edit_collection(request.user, collection),
}) })
@@ -864,8 +902,19 @@ def collection_manage(request, pk):
return redirect(redirect_url) return redirect(redirect_url)
add_collection_section(collection, item) add_collection_section(collection, item)
return redirect(redirect_url) return redirect(redirect_url)
if action == 'create_chapter':
title = request.POST.get('title', '').strip()[:200]
if title:
chapter = Collection.objects.create(
owner=request.user, title=title, mode=Collection.Mode.CHAPTER,
visibility=collection.visibility, team=collection.team,
)
add_collection_subcollection(collection, chapter)
return redirect(reverse('collection_manage', args=[chapter.pk]))
messages.error(request, _('Enter a chapter title.'))
return redirect(redirect_url)
if action == 'add_collection': if action == 'add_collection':
sub_collection = get_object_or_404(visible_collections(request.user), pk=request.POST.get('collection_id'), mode=Collection.Mode.MANUAL) sub_collection = get_object_or_404(visible_collections(request.user).filter(mode__in=Collection.STRUCTURED_MODES), pk=request.POST.get('collection_id'))
if sub_collection.pk == collection.pk or collection_contains_collection(sub_collection, collection): if sub_collection.pk == collection.pk or collection_contains_collection(sub_collection, collection):
messages.error(request, _('This would create a collection loop.')) messages.error(request, _('This would create a collection loop.'))
elif not collection_subcollection_is_compatible(collection, sub_collection): elif not collection_subcollection_is_compatible(collection, sub_collection):
@@ -891,6 +940,19 @@ def collection_manage(request, pk):
CollectionSection.objects.filter(pk=section.pk).update(position=temporary) CollectionSection.objects.filter(pk=section.pk).update(position=temporary)
CollectionSection.objects.filter(pk=other.pk).update(position=section.position) CollectionSection.objects.filter(pk=other.pk).update(position=section.position)
CollectionSection.objects.filter(pk=section.pk).update(position=other.position) CollectionSection.objects.filter(pk=section.pk).update(position=other.position)
elif action == 'move_to':
try:
new_position = int(request.POST.get('position', section.position))
except (TypeError, ValueError):
new_position = section.position
ordered = list(collection.sections.order_by('position', 'id'))
ordered.remove(section)
ordered.insert(max(0, min(new_position, len(ordered))), section)
with transaction.atomic():
for index, current in enumerate(ordered):
CollectionSection.objects.filter(pk=current.pk).update(position=1000 + index)
for index, current in enumerate(ordered):
CollectionSection.objects.filter(pk=current.pk).update(position=index)
return redirect(redirect_url) return redirect(redirect_url)
return render(request, 'core/collection_manage.html', collection_detail_context(request, collection)) return render(request, 'core/collection_manage.html', collection_detail_context(request, collection))
+41
View File
@@ -425,6 +425,11 @@ msgstr "Journal-Eintrag erstellen"
msgid "Create note" msgid "Create note"
msgstr "Notiz erstellen" msgstr "Notiz erstellen"
#: core/templates/core/new_item.html
#, python-format
msgid "Create note for %(title)s"
msgstr "Notiz für %(title)s erstellen"
#: core/templates/core/new_item.html:24 #: core/templates/core/new_item.html:24
msgid "Add image" msgid "Add image"
msgstr "Bild hinzufügen" msgstr "Bild hinzufügen"
@@ -780,6 +785,42 @@ msgstr "Manuelle Sammlung"
msgid "Tag collection" msgid "Tag collection"
msgstr "Tag-Sammlung" msgstr "Tag-Sammlung"
#: core/models.py
msgid "Tutorial"
msgstr "Tutorial"
#: core/models.py
msgid "Chapter"
msgstr "Kapitel"
#: core/templates/core/collection_list.html
msgid "Chapters"
msgstr "Kapitel"
#: core/templates/core/collection_list.html
msgid "Orphan chapters"
msgstr "Verwaiste Kapitel"
#: core/templates/core/collection_list.html
msgid "These chapters are not used by any tutorial yet:"
msgstr "Diese Kapitel werden noch in keinem Tutorial verwendet:"
#: core/templates/core/collection_manage.html
msgid "Drag to reorder"
msgstr "Ziehen zum Sortieren"
#: core/templates/core/collection_manage.html
msgid "New chapter title"
msgstr "Titel des neuen Kapitels"
#: core/templates/core/collection_manage.html
msgid "Create chapter"
msgstr "Kapitel erstellen"
#: core/views.py
msgid "Enter a chapter title."
msgstr "Gib einen Kapiteltitel ein."
#: core/forms.py #: core/forms.py
msgid "Collection type" msgid "Collection type"
msgstr "Art der Sammlung" msgstr "Art der Sammlung"