From cd97a6cb629b9bea371f1ae6423bcfd935bc15e5 Mon Sep 17 00:00:00 2001 From: rucki Date: Thu, 27 Aug 2026 14:06:06 +0200 Subject: [PATCH] Add desktop note collections --- core/admin.py | 14 +- core/forms.py | 20 +- .../0017_collection_collectionsection.py | 48 ++++ core/models.py | 40 ++- core/templates/core/_edit_form.html | 1 + core/templates/core/base.html | 2 +- core/templates/core/collection_detail.html | 67 +++++ core/templates/core/collection_form.html | 18 ++ core/templates/core/collection_list.html | 20 ++ core/templates/core/item_editor.html | 1 + core/tests.py | 83 +++++- core/urls.py | 5 + core/views.py | 266 +++++++++++++++++- locale/de/LC_MESSAGES/django.po | 156 ++++++++++ 14 files changed, 731 insertions(+), 10 deletions(-) create mode 100644 core/migrations/0017_collection_collectionsection.py create mode 100644 core/templates/core/collection_detail.html create mode 100644 core/templates/core/collection_form.html create mode 100644 core/templates/core/collection_list.html diff --git a/core/admin.py b/core/admin.py index d8867b2..82fed48 100644 --- a/core/admin.py +++ b/core/admin.py @@ -1,5 +1,17 @@ from django.contrib import admin -from .models import ApiKey, Attachment, Item, Kanban, Tag, Team, TeamInvite, TeamMembership, UserInvite, UserPreference +from .models import ApiKey, Attachment, Collection, CollectionSection, Item, Kanban, Tag, Team, TeamInvite, TeamMembership, UserInvite, UserPreference + + +class CollectionSectionInline(admin.TabularInline): + model = CollectionSection + extra = 0 + + +@admin.register(Collection) +class CollectionAdmin(admin.ModelAdmin): + list_display = ('title', 'visibility', 'team', 'owner', 'updated_at') + list_filter = ('visibility', 'team') + inlines = [CollectionSectionInline] class AttachmentInline(admin.TabularInline): diff --git a/core/forms.py b/core/forms.py index abb4b1e..196ea61 100644 --- a/core/forms.py +++ b/core/forms.py @@ -2,7 +2,7 @@ from django import forms from django.contrib.auth.forms import UserCreationForm from django.contrib.auth.models import User from django.utils.translation import gettext_lazy as _ -from .models import ApiKey, Attachment, Item, Kanban, Tag, Team, TeamInvite, UserInvite, UserPreference +from .models import ApiKey, Attachment, Collection, Item, Kanban, Tag, Team, TeamInvite, UserInvite, UserPreference class MultipleFileInput(forms.ClearableFileInput): @@ -133,6 +133,24 @@ class KanbanForm(forms.ModelForm): return super().save(commit=commit) +class CollectionForm(forms.ModelForm): + class Meta: + model = Collection + fields = ['title', 'description', 'visibility', 'tags'] + labels = { + 'title': _('Title'), + 'description': _('Description'), + 'visibility': _('Visibility'), + 'tags': _('Tags'), + } + widgets = { + 'title': forms.TextInput(attrs={'class': 'form-control'}), + 'description': forms.Textarea(attrs={'class': 'form-control', 'rows': 4}), + 'visibility': forms.Select(attrs={'class': 'form-select'}), + 'tags': forms.CheckboxSelectMultiple(), + } + + class ApiKeyForm(forms.ModelForm): class Meta: model = ApiKey diff --git a/core/migrations/0017_collection_collectionsection.py b/core/migrations/0017_collection_collectionsection.py new file mode 100644 index 0000000..578a978 --- /dev/null +++ b/core/migrations/0017_collection_collectionsection.py @@ -0,0 +1,48 @@ +# Generated by Django 5.2.17 on 2026-08-27 12:01 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0016_userpreference_default_item_kind'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Collection', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=200)), + ('description', models.TextField(blank=True)), + ('visibility', models.CharField(choices=[('private', 'Private'), ('team', 'Team'), ('public', 'Public')], default='private', max_length=10)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='collections', to=settings.AUTH_USER_MODEL)), + ('tags', models.ManyToManyField(blank=True, related_name='collections', to='core.tag')), + ('team', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='collections', to='core.team')), + ], + options={ + 'ordering': ['-updated_at'], + }, + ), + migrations.CreateModel( + name='CollectionSection', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(blank=True, max_length=200)), + ('position', models.PositiveIntegerField(default=0)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('collection', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sections', to='core.collection')), + ('item', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='collection_sections', to='core.item')), + ], + options={ + 'ordering': ['position', 'id'], + 'constraints': [models.UniqueConstraint(fields=('collection', 'item'), name='unique_collection_item'), models.UniqueConstraint(fields=('collection', 'position'), name='unique_collection_position')], + }, + ), + ] diff --git a/core/models.py b/core/models.py index b7c90ec..27ee95b 100644 --- a/core/models.py +++ b/core/models.py @@ -35,7 +35,7 @@ def without_markdown_code(text): def delete_unused_tags(tags=None): """Delete tags that are no longer used by items or tag-based features.""" candidates = Tag.objects.all() if tags is None else Tag.objects.filter(pk__in=[tag.pk for tag in tags]) - candidates.filter(items__isnull=True, kanbans__isnull=True, api_keys__isnull=True).exclude( + candidates.filter(items__isnull=True, kanbans__isnull=True, api_keys__isnull=True, collections__isnull=True).exclude( name__in=Team.objects.values_list('slug', flat=True), ).delete() @@ -195,6 +195,44 @@ class Kanban(models.Model): return self.name +class Collection(models.Model): + 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') + title = models.CharField(max_length=200) + description = models.TextField(blank=True) + visibility = models.CharField(max_length=10, choices=Item.Visibility.choices, default=Item.Visibility.PRIVATE) + tags = models.ManyToManyField(Tag, blank=True, related_name='collections') + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ['-updated_at'] + + def __str__(self): + return self.title + + def get_absolute_url(self): + return reverse('collection_detail', args=[self.pk]) + + +class CollectionSection(models.Model): + collection = models.ForeignKey(Collection, on_delete=models.CASCADE, related_name='sections') + item = models.ForeignKey(Item, on_delete=models.PROTECT, related_name='collection_sections') + title = models.CharField(max_length=200, blank=True) + position = models.PositiveIntegerField(default=0) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ['position', 'id'] + constraints = [ + models.UniqueConstraint(fields=['collection', 'item'], name='unique_collection_item'), + models.UniqueConstraint(fields=['collection', 'position'], name='unique_collection_position'), + ] + + def __str__(self): + return f'{self.collection}: {self.title or self.item}' + + class UserPreference(models.Model): user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='preferences') language = models.CharField(max_length=10, blank=True, default='', choices=[('', _('Browser default')), ('de', 'Deutsch'), ('en', 'English')]) diff --git a/core/templates/core/_edit_form.html b/core/templates/core/_edit_form.html index 4a0c152..e8fe1c1 100644 --- a/core/templates/core/_edit_form.html +++ b/core/templates/core/_edit_form.html @@ -2,6 +2,7 @@
{% csrf_token %}
+ {% if form.errors %}
{{ form.errors }}
{% endif %}
{% trans "Type:" %} {{ item.get_kind_display }}
{% trans "Add tags" %}
diff --git a/core/templates/core/base.html b/core/templates/core/base.html index a42a5ea..2d70fb9 100644 --- a/core/templates/core/base.html +++ b/core/templates/core/base.html @@ -26,7 +26,7 @@
Start{% if user.is_authenticated %}{% trans "Journal" %}{% trans "Teams" %}{% endif %}
{% if user.is_authenticated %} diff --git a/core/templates/core/collection_detail.html b/core/templates/core/collection_detail.html new file mode 100644 index 0000000..c4be5ce --- /dev/null +++ b/core/templates/core/collection_detail.html @@ -0,0 +1,67 @@ +{% extends 'core/base.html' %} +{% load i18n markdown_extras %} +{% block content %} +
{% trans "Collections are currently available on desktop only." %}
+
+
+
+ ← {% trans "Collections" %} +

{{ collection.title }}

+
+ {% if collection.team %}{{ collection.team.name }}{% else %}{{ collection.get_visibility_display }}{% endif %} + {% for tag in collection.tags.all %}#{{ tag.name }}{% endfor %} +
+
+ {% if can_edit %}
{% trans "Edit collection" %}{% if can_delete %}{% csrf_token %}{% endif %}
{% endif %} +
+ + {% if collection.description %}
{{ collection.description|markdown }}
{% endif %} + + {% if sections %} +
{% trans "Contents" %}
    {% for section in sections %}
  1. {{ section.title|default:section.item.content|truncatechars:90 }}
  2. {% endfor %}
+ {% endif %} + + {% if pending_item %} +
+

{% trans "The note has a different visibility" %}

+

{% trans "To add it, change its visibility or create a separate copy for this collection." %}

+
{{ pending_item.content|truncatechars:180 }}
+
+ {% if pending_can_convert %}
{% csrf_token %}
{% endif %} +
{% csrf_token %}
+ {% trans "Cancel" %} +
+ {% if not pending_can_convert %}
{% trans "The original cannot be changed because it is used elsewhere or you cannot edit it." %}
{% endif %} +
+ {% endif %} + +
+ {% for section in sections %} +
+
+

{{ section.title|default:section.item.content|truncatechars:100 }}

+ #{{ section.item.id }} +
+
{{ section.item|item_markdown }}
+ {% if section.item.comment %}
{{ section.item.comment|markdown }}
{% endif %} + {% if can_edit %}
+
{% csrf_token %}
+
{% csrf_token %}
+
{% csrf_token %}
+
{% csrf_token %}
+
{% endif %} +
+ {% empty %}
{% trans "This collection has no sections yet." %}
{% endfor %} +
+ + {% if can_edit %} +
+

{% trans "Add existing note" %}

+
+
+ {% for item in candidates %}
{{ item.content|truncatechars:180 }}
#{{ item.id }} · {{ item.get_visibility_display }}{% if item.team %} · {{ item.team.name }}{% endif %}
{% csrf_token %}
{% empty %}
{% trans "No matching notes." %}
{% endfor %} +
+
+ {% endif %} +
+{% endblock %} diff --git a/core/templates/core/collection_form.html b/core/templates/core/collection_form.html new file mode 100644 index 0000000..eacc44e --- /dev/null +++ b/core/templates/core/collection_form.html @@ -0,0 +1,18 @@ +{% extends 'core/base.html' %} +{% load i18n %} +{% block content %} +
{% trans "Collections are currently available on desktop only." %}
+
+ ← {% trans "back" %} +
+

{{ heading }}

+ {% csrf_token %} + {% if form.non_field_errors %}
{{ form.non_field_errors }}
{% endif %} +
{{ form.title }}{{ form.title.errors }}
+
{{ form.description }}{{ form.description.errors }}
+
{{ form.visibility }}{{ form.visibility.errors }}
{% trans "Adding one of your team tags turns this into a team collection." %}
+
{{ form.tags }}
{{ form.tags.errors }}
+
{% trans "Cancel" %}
+
+
+{% endblock %} diff --git a/core/templates/core/collection_list.html b/core/templates/core/collection_list.html new file mode 100644 index 0000000..07f3631 --- /dev/null +++ b/core/templates/core/collection_list.html @@ -0,0 +1,20 @@ +{% extends 'core/base.html' %} +{% load i18n %} +{% block content %} +
{% trans "Collections are currently available on desktop only." %}
+ +{% endblock %} diff --git a/core/templates/core/item_editor.html b/core/templates/core/item_editor.html index 9196561..f219ddc 100644 --- a/core/templates/core/item_editor.html +++ b/core/templates/core/item_editor.html @@ -4,6 +4,7 @@
{% csrf_token %}
+ {% if form.errors %}
{{ form.errors }}
{% endif %}

{% blocktrans with id=item.id %}Edit item #{{ id }}{% endblocktrans %}

×
diff --git a/core/tests.py b/core/tests.py index 6012052..0277523 100644 --- a/core/tests.py +++ b/core/tests.py @@ -2,7 +2,7 @@ from django.contrib.auth import get_user_model from django.test import TestCase from django.urls import reverse -from .models import Item, Tag, UserPreference +from .models import Collection, CollectionSection, Item, Tag, Team, TeamMembership, UserPreference from .templatetags.markdown_extras import markdown from .views import parse_due_command, parse_quick_content @@ -147,6 +147,87 @@ class NavigationAndLayoutTests(TestCase): self.assertLess(toolbar_position, file_position) +class CollectionTests(TestCase): + def setUp(self): + self.user = get_user_model().objects.create_user(username='collector', password='secret') + self.client.force_login(self.user) + + def test_create_collection_and_add_compatible_note(self): + response = self.client.post(reverse('collection_create'), { + 'title': 'Documentation', 'description': 'Guide', 'visibility': Item.Visibility.PRIVATE, + }) + collection = Collection.objects.get() + self.assertRedirects(response, collection.get_absolute_url()) + note = Item.objects.create(owner=self.user, kind=Item.Kind.NOTE, content='Introduction') + response = self.client.post(collection.get_absolute_url(), {'action': 'add', 'item_id': note.id}) + self.assertRedirects(response, collection.get_absolute_url()) + self.assertTrue(CollectionSection.objects.filter(collection=collection, item=note).exists()) + + def test_public_collection_asks_before_copying_private_note(self): + collection = Collection.objects.create(owner=self.user, title='Public guide', visibility=Item.Visibility.PUBLIC) + note = Item.objects.create(owner=self.user, kind=Item.Kind.NOTE, content='Private section') + response = self.client.post(collection.get_absolute_url(), {'action': 'add', 'item_id': note.id}) + self.assertContains(response, 'name="mode" value="copy"') + + response = self.client.post(collection.get_absolute_url(), { + 'action': 'confirm_add', 'item_id': note.id, 'mode': 'copy', + }) + self.assertRedirects(response, collection.get_absolute_url()) + copied = collection.sections.get().item + self.assertNotEqual(copied.id, note.id) + self.assertEqual(copied.visibility, Item.Visibility.PUBLIC) + note.refresh_from_db() + self.assertEqual(note.visibility, Item.Visibility.PRIVATE) + + def test_note_used_by_collection_cannot_be_deleted(self): + collection = Collection.objects.create(owner=self.user, title='Protected') + note = Item.objects.create(owner=self.user, kind=Item.Kind.NOTE, content='Do not delete') + CollectionSection.objects.create(collection=collection, item=note, position=0) + response = self.client.post(reverse('delete_item', args=[note.id])) + self.assertEqual(response.status_code, 409) + self.assertTrue(Item.objects.filter(pk=note.id).exists()) + + def test_team_tag_creates_team_collection(self): + team = Team.objects.create(name='Docs team', slug='docs', owner=self.user) + TeamMembership.objects.create(team=team, user=self.user, role=TeamMembership.Role.OWNER) + tag = Tag.objects.create(name='docs') + response = self.client.post(reverse('collection_create'), { + 'title': 'Team guide', 'visibility': Item.Visibility.PRIVATE, 'tags': [tag.id], + }) + collection = Collection.objects.get() + self.assertRedirects(response, collection.get_absolute_url()) + self.assertEqual(collection.team, team) + self.assertEqual(collection.visibility, Item.Visibility.TEAM) + + def test_public_collection_is_visible_without_login(self): + collection = Collection.objects.create(owner=self.user, title='Public', visibility=Item.Visibility.PUBLIC) + self.client.logout() + self.assertEqual(self.client.get(collection.get_absolute_url()).status_code, 200) + + def test_collection_tag_is_not_removed_as_unused(self): + tag = Tag.objects.create(name='documentation') + collection = Collection.objects.create(owner=self.user, title='Tagged') + collection.tags.add(tag) + note = Item.objects.create(owner=self.user, content='#temporary') + note.sync_metadata() + note.content = '' + note.save() + note.sync_metadata() + self.assertTrue(Tag.objects.filter(pk=tag.pk).exists()) + + def test_api_cannot_make_collection_note_incompatible(self): + collection = Collection.objects.create(owner=self.user, title='Public', visibility=Item.Visibility.PUBLIC) + note = Item.objects.create(owner=self.user, kind=Item.Kind.NOTE, content='Published', visibility=Item.Visibility.PUBLIC) + CollectionSection.objects.create(collection=collection, item=note, position=0) + response = self.client.patch( + reverse('api_item_detail', args=[note.id]), + data='{"visibility":"private"}', content_type='application/json', + ) + self.assertEqual(response.status_code, 409) + note.refresh_from_db() + self.assertEqual(note.visibility, Item.Visibility.PUBLIC) + + class CopyButtonTests(TestCase): def test_item_has_copy_button_with_original_text(self): user = get_user_model().objects.create_user(username='tester', password='secret') diff --git a/core/urls.py b/core/urls.py index 698caa1..bbd82bc 100644 --- a/core/urls.py +++ b/core/urls.py @@ -19,6 +19,11 @@ urlpatterns = [ path('teams/', views.team_list, name='team_list'), path('teams//', views.team_detail, name='team_detail'), path('teams/invite//', views.team_accept_invite, name='team_accept_invite'), + path('collections/', views.collection_list, name='collection_list'), + path('collections/new/', views.collection_create, name='collection_create'), + path('collections//', views.collection_detail, name='collection_detail'), + path('collections//edit/', views.collection_edit, name='collection_edit'), + path('collections//delete/', views.collection_delete, name='collection_delete'), path('items//', views.item_detail, name='item_detail'), path('items//card/', views.item_card, name='item_card'), path('items//edit/', views.edit_item, name='edit_item'), diff --git a/core/views.py b/core/views.py index e365e04..8f380de 100644 --- a/core/views.py +++ b/core/views.py @@ -9,7 +9,8 @@ from django.conf import settings from django.contrib import messages from django.contrib.auth import login from django.contrib.auth.decorators import login_required -from django.db.models import Case, IntegerField, Q, Value, When +from django.db import transaction +from django.db.models import Case, IntegerField, ProtectedError, Q, Value, When from django.http import HttpResponse, JsonResponse from django.urls import reverse from django.utils import timezone @@ -18,8 +19,8 @@ from django.utils.translation import gettext as _ from django.shortcuts import get_object_or_404, redirect, render from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_http_methods, require_POST -from .forms import ApiKeyForm, EditItemForm, InviteRegistrationForm, KanbanForm, QuickItemForm, TeamForm, TeamInviteForm, UserInviteForm, UserPreferenceForm, UserSettingsForm -from .models import ApiKey, Attachment, Item, Kanban, Tag, Team, TeamInvite, TeamMembership, UserInvite, UserPreference +from .forms import ApiKeyForm, CollectionForm, EditItemForm, InviteRegistrationForm, KanbanForm, QuickItemForm, TeamForm, TeamInviteForm, UserInviteForm, UserPreferenceForm, UserSettingsForm +from .models import ApiKey, Attachment, Collection, CollectionSection, Item, Kanban, Tag, Team, TeamInvite, TeamMembership, UserInvite, UserPreference TYPE_COMMAND_RE = re.compile(r'^/(todo|note|link|journal)\b', re.I) VISIBILITY_COMMAND_RE = re.compile(r'/(public|private)\b', re.I) @@ -159,6 +160,22 @@ def user_can_delete_item(user, item): return item.owner_id == user.id or (item.team_id and TeamMembership.objects.filter(team=item.team, user=user, role__in=[TeamMembership.Role.OWNER, TeamMembership.Role.ADMIN]).exists()) +def desired_item_scope(item, user, force_private=False): + if force_private: + return Item.Visibility.PRIVATE, None + tag_names = {match.group(1).lower() for match in re.finditer(r'(?