diff --git a/core/admin.py b/core/admin.py index 82fed48..1bc4593 100644 --- a/core/admin.py +++ b/core/admin.py @@ -9,8 +9,9 @@ class CollectionSectionInline(admin.TabularInline): @admin.register(Collection) class CollectionAdmin(admin.ModelAdmin): - list_display = ('title', 'visibility', 'team', 'owner', 'updated_at') - list_filter = ('visibility', 'team') + list_display = ('title', 'mode', 'visibility', 'team', 'owner', 'updated_at') + list_filter = ('mode', 'visibility', 'team') + filter_horizontal = ('filter_tags',) inlines = [CollectionSectionInline] diff --git a/core/forms.py b/core/forms.py index 4474513..fc8e5bf 100644 --- a/core/forms.py +++ b/core/forms.py @@ -1,6 +1,7 @@ from django import forms from django.contrib.auth.forms import UserCreationForm from django.contrib.auth.models import User +from django.db.models import Q from django.utils.translation import gettext_lazy as _ from .models import ApiKey, Attachment, Collection, Item, Kanban, Tag, Team, TeamInvite, UserInvite, UserPreference @@ -134,34 +135,47 @@ class KanbanForm(forms.ModelForm): class CollectionForm(forms.ModelForm): + team = forms.ModelChoiceField(queryset=Team.objects.none(), required=False, label=_('Team'), widget=forms.Select(attrs={'class': 'form-select'})) + def __init__(self, *args, user=None, **kwargs): super().__init__(*args, **kwargs) + self.fields['visibility'].choices = [ + (Item.Visibility.PRIVATE, _('Private')), + (Item.Visibility.PUBLIC, _('Public')), + ] if user and user.is_authenticated: - team_slugs = user.team_memberships.values_list('team__slug', flat=True) - self.fields['tags'].queryset = Tag.objects.filter(name__in=team_slugs).order_by('name') + team_ids = user.team_memberships.values_list('team_id', flat=True) + self.fields['team'].queryset = Team.objects.filter(pk__in=team_ids).order_by('name') + self.fields['team'].initial = self.instance.team_id if self.instance and self.instance.pk else None + item_scope = Q(items__owner=user) | Q(items__team_id__in=team_ids) + team_slugs = Team.objects.values_list('slug', flat=True) + self.fields['filter_tags'].queryset = Tag.objects.filter(item_scope).exclude(name__in=team_slugs).distinct().order_by('name') else: - self.fields['tags'].queryset = Tag.objects.none() + self.fields['team'].queryset = Team.objects.none() + self.fields['filter_tags'].queryset = Tag.objects.none() - def clean_tags(self): - tags = self.cleaned_data['tags'] - if tags.count() > 1: - raise forms.ValidationError(_('Select at most one team.')) - return tags + def clean(self): + cleaned = super().clean() + if cleaned.get('mode') == Collection.Mode.TAGS and not cleaned.get('filter_tags'): + self.add_error('filter_tags', _('Select at least one tag for a tag collection.')) + return cleaned class Meta: model = Collection - fields = ['title', 'description', 'visibility', 'tags'] + fields = ['title', 'description', 'visibility', 'mode', 'team', 'filter_tags'] labels = { 'title': _('Title'), 'description': _('Description'), 'visibility': _('Visibility'), - 'tags': _('Team'), + 'mode': _('Collection type'), + 'filter_tags': _('Content 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(), + 'mode': forms.Select(attrs={'class': 'form-select', 'x-model': 'mode'}), + 'filter_tags': forms.CheckboxSelectMultiple(), } diff --git a/core/migrations/0018_remove_collection_tags_collection_filter_tags_and_more.py b/core/migrations/0018_remove_collection_tags_collection_filter_tags_and_more.py new file mode 100644 index 0000000..3c31ffe --- /dev/null +++ b/core/migrations/0018_remove_collection_tags_collection_filter_tags_and_more.py @@ -0,0 +1,27 @@ +# Generated by Django 5.2.17 on 2026-08-27 14:47 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0017_collection_collectionsection'), + ] + + operations = [ + migrations.RemoveField( + model_name='collection', + name='tags', + ), + migrations.AddField( + model_name='collection', + name='filter_tags', + field=models.ManyToManyField(blank=True, related_name='tag_collections', to='core.tag'), + ), + migrations.AddField( + model_name='collection', + name='mode', + field=models.CharField(choices=[('manual', 'Manual collection'), ('tags', 'Tag collection')], default='manual', max_length=10), + ), + ] diff --git a/core/models.py b/core/models.py index 27ee95b..0eba004 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, collections__isnull=True).exclude( + candidates.filter(items__isnull=True, kanbans__isnull=True, api_keys__isnull=True, tag_collections__isnull=True).exclude( name__in=Team.objects.values_list('slug', flat=True), ).delete() @@ -196,12 +196,17 @@ class Kanban(models.Model): class Collection(models.Model): + class Mode(models.TextChoices): + MANUAL = 'manual', _('Manual collection') + TAGS = 'tags', _('Tag collection') + 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') + mode = models.CharField(max_length=10, choices=Mode.choices, default=Mode.MANUAL) + filter_tags = models.ManyToManyField(Tag, blank=True, related_name='tag_collections') created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) diff --git a/core/templates/core/collection_detail.html b/core/templates/core/collection_detail.html index f2b7c5f..069e0ef 100644 --- a/core/templates/core/collection_detail.html +++ b/core/templates/core/collection_detail.html @@ -9,30 +9,29 @@ ← {% if user.is_authenticated %}{% trans "Collections" %}{% else %}Start{% endif %}

{{ collection.title }}

+ {% if collection.mode == 'tags' %}{% trans "Tag collection" %}{% endif %} {% if collection.team %}{{ collection.team.name }}{% else %}{{ collection.get_visibility_display }}{% endif %} - {% for tag in collection.tags.all %}#{{ tag.name }}{% endfor %} + {% for tag in collection.filter_tags.all %}#{{ tag.name }}{% endfor %}
- {% if can_edit %}{% trans "Edit collection" %}{% endif %} + {% if can_edit %}{% trans "Edit collection" %}{% endif %} {% if collection.description %}
{{ collection.description|markdown }}
{% endif %} - {% if sections %} + {% if sections or tag_items %} {% endif %}
- {% for section in sections %} -
- {% if section.title %}

{{ section.title }}

{% endif %} - {{ section.item|item_markdown }} - {% if section.item.comment %}
{{ section.item.comment|markdown }}
{% endif %} -
- {% empty %}
{% trans "This collection has no sections yet." %}
{% endfor %} + {% if collection.mode == 'tags' %} + {% for item in tag_items %}
{{ item|item_markdown }}{% if item.comment %}
{{ item.comment|markdown }}
{% endif %}
{% empty %}
{% trans "No notes match the selected tags yet." %}
{% endfor %} + {% else %} + {% for section in sections %}
{% if section.title %}

{{ section.title }}

{% endif %}{{ section.item|item_markdown }}{% if section.item.comment %}
{{ section.item.comment|markdown }}
{% endif %}
{% empty %}
{% trans "This collection has no sections yet." %}
{% endfor %} + {% endif %}
{% endblock %} diff --git a/core/templates/core/collection_form.html b/core/templates/core/collection_form.html index 52f6007..a4d39c9 100644 --- a/core/templates/core/collection_form.html +++ b/core/templates/core/collection_form.html @@ -4,14 +4,16 @@
{% 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 "Optional. Selecting a team makes the collection visible and editable for that team." %}
-
{% if form.tags.field.queryset.exists %}{{ form.tags }}{% else %}{% trans "You do not belong to a team." %}{% endif %}
{{ form.tags.errors }}
+
{{ form.mode }}{{ form.mode.errors }}
{% trans "Tag collections update automatically and are sorted by note creation time." %}
+
{{ form.visibility }}{{ form.visibility.errors }}
+
{{ form.team }}{{ form.team.errors }}
{% trans "Optional. Selecting a team makes the collection visible and editable for that team." %}
+
{% if form.filter_tags.field.queryset.exists %}{{ form.filter_tags }}{% else %}{% trans "No content tags are available yet." %}{% endif %}
{{ form.filter_tags.errors }}
{% trans "Notes must contain all selected tags. Team tags are not content filters." %}
{% trans "Cancel" %}
diff --git a/core/templates/core/collection_list.html b/core/templates/core/collection_list.html index 07f3631..044f23f 100644 --- a/core/templates/core/collection_list.html +++ b/core/templates/core/collection_list.html @@ -8,13 +8,14 @@ {% trans "New collection" %}
- {% for collection in collections %} + {% for entry in entries %}{% with collection=entry.collection %}
-

{{ collection.title }}

{% if collection.team %}{{ collection.team.name }}{% else %}{{ collection.get_visibility_display }}{% endif %}
+

{{ collection.title }}

{% if collection.mode == 'tags' %}{% trans "Tag collection" %}{% endif %}{% if collection.team %}{{ collection.team.name }}{% else %}{{ collection.get_visibility_display }}{% endif %}
{% if collection.description %}

{{ collection.description|truncatechars:140 }}

{% endif %} -
{{ collection.sections.count }} {% trans "sections" %} · {{ collection.updated_at|date:'d.m.Y H:i' }}
+ {% if collection.mode == 'tags' %}
{% for tag in collection.filter_tags.all %}#{{ tag.name }} {% endfor %}
{% endif %} +
{{ entry.section_count }} {% trans "sections" %} · {{ collection.updated_at|date:'d.m.Y H:i' }}
- {% empty %}
{% trans "No collections yet." %}
{% endfor %} + {% endwith %}{% empty %}
{% trans "No collections yet." %}
{% endfor %}
{% endblock %} diff --git a/core/templates/core/collection_manage.html b/core/templates/core/collection_manage.html index fb483ae..bb22d1e 100644 --- a/core/templates/core/collection_manage.html +++ b/core/templates/core/collection_manage.html @@ -9,7 +9,7 @@

{{ collection.title }}

{% if collection.team %}{{ collection.team.name }}{% else %}{{ collection.get_visibility_display }}{% endif %} - {% for tag in collection.tags.all %}#{{ tag.name }}{% endfor %} + {% for tag in collection.filter_tags.all %}#{{ tag.name }}{% endfor %}
{% if can_edit %}
{% trans "Collection settings" %}{% if can_delete %}
{% csrf_token %}
{% endif %}
{% endif %} diff --git a/core/tests.py b/core/tests.py index f371fab..af767c7 100644 --- a/core/tests.py +++ b/core/tests.py @@ -154,7 +154,7 @@ class CollectionTests(TestCase): def test_create_collection_and_add_compatible_note(self): response = self.client.post(reverse('collection_create'), { - 'title': 'Documentation', 'description': 'Guide', 'visibility': Item.Visibility.PRIVATE, + 'title': 'Documentation', 'description': 'Guide', 'visibility': Item.Visibility.PRIVATE, 'mode': Collection.Mode.MANUAL, }) collection = Collection.objects.get() self.assertRedirects(response, collection.get_absolute_url()) @@ -199,12 +199,12 @@ class CollectionTests(TestCase): self.assertEqual(response.status_code, 409) self.assertTrue(Item.objects.filter(pk=note.id).exists()) - def test_team_tag_creates_team_collection(self): + def test_team_selection_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], + 'title': 'Team guide', 'visibility': Item.Visibility.PRIVATE, + 'mode': Collection.Mode.MANUAL, 'team': team.id, }) collection = Collection.objects.get() self.assertRedirects(response, collection.get_absolute_url()) @@ -218,13 +218,36 @@ class CollectionTests(TestCase): response = self.client.get(reverse('collection_manage', args=[collection.id])) self.assertNotContains(response, f'name="item_id" value="{public_note.id}"') - def test_collection_form_only_offers_team_tags(self): + def test_collection_form_separates_teams_and_content_tags(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.objects.create(name='docs') - Tag.objects.create(name='ordinary') + note = Item.objects.create(owner=self.user, content='#ordinary') + note.sync_metadata() response = self.client.get(reverse('collection_create')) - self.assertEqual(list(response.context['form'].fields['tags'].queryset.values_list('name', flat=True)), ['docs']) + form = response.context['form'] + self.assertEqual(list(form.fields['team'].queryset), [team]) + self.assertEqual(list(form.fields['filter_tags'].queryset.values_list('name', flat=True)), ['ordinary']) + + 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.sync_metadata() + Item.objects.create(owner=self.user, kind=Item.Kind.NOTE, content='Ignored #guide').sync_metadata() + second = Item.objects.create(owner=self.user, kind=Item.Kind.NOTE, content='Second #guide #basics') + second.sync_metadata() + collection = Collection.objects.create(owner=self.user, title='Automatic', mode=Collection.Mode.TAGS) + collection.filter_tags.set(Tag.objects.filter(name__in=['guide', 'basics'])) + + response = self.client.get(collection.get_absolute_url()) + html = response.content.decode() + self.assertContains(response, 'Tag-Sammlung') + self.assertLess(html.index('First #guide'), html.index('Second #guide')) + self.assertNotContains(response, 'Ignored #guide') + self.assertContains(response, reverse('collection_edit', args=[collection.id])) + self.assertNotContains(response, reverse('collection_manage', args=[collection.id])) + + manage_response = self.client.get(reverse('collection_manage', args=[collection.id])) + self.assertRedirects(manage_response, reverse('collection_edit', args=[collection.id])) def test_collection_opens_as_continuous_reading_view(self): collection = Collection.objects.create(owner=self.user, title='Reading view') @@ -247,8 +270,8 @@ class CollectionTests(TestCase): 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) + collection = Collection.objects.create(owner=self.user, title='Tagged', mode=Collection.Mode.TAGS) + collection.filter_tags.add(tag) note = Item.objects.create(owner=self.user, content='#temporary') note.sync_metadata() note.content = '' diff --git a/core/views.py b/core/views.py index 09656ec..9d8087e 100644 --- a/core/views.py +++ b/core/views.py @@ -536,7 +536,7 @@ def team_accept_invite(request, token): def visible_collections(user): - qs = Collection.objects.select_related('owner', 'team').prefetch_related('tags', 'sections__item') + qs = Collection.objects.select_related('owner', 'team').prefetch_related('filter_tags', 'sections__item') if user.is_authenticated: return qs.filter(Q(owner=user) | Q(team_id__in=user_team_ids(user)) | Q(visibility=Item.Visibility.PUBLIC)).distinct() return qs.filter(visibility=Item.Visibility.PUBLIC) @@ -569,14 +569,25 @@ def collection_item_is_compatible(collection, item): def collection_target_from_form(form, user): - tags = form.cleaned_data.get('tags') - tag_names = list(tags.values_list('name', flat=True)) if tags is not None else [] - membership = TeamMembership.objects.select_related('team').filter(user=user, team__slug__in=tag_names).first() - if membership: - return Item.Visibility.TEAM, membership.team + team = form.cleaned_data.get('team') + if team: + return Item.Visibility.TEAM, team return form.cleaned_data['visibility'], None +def collection_tag_items(collection): + items = Item.objects.filter(kind=Item.Kind.NOTE).select_related('owner', 'team').prefetch_related('tags', 'attachments') + if collection.visibility == Item.Visibility.PUBLIC: + items = items.filter(visibility=Item.Visibility.PUBLIC) + elif collection.visibility == Item.Visibility.TEAM: + items = items.filter(Q(visibility=Item.Visibility.PUBLIC) | Q(visibility=Item.Visibility.TEAM, team=collection.team)) + else: + items = items.filter(Q(visibility=Item.Visibility.PUBLIC) | Q(visibility=Item.Visibility.PRIVATE, owner=collection.owner)) + for tag in collection.filter_tags.all(): + items = items.filter(tags=tag) + return items.distinct().order_by('created_at', 'id') + + def item_can_change_for_collection(user, item, collection): if not user_can_edit_item(user, item): return False @@ -659,8 +670,11 @@ def collection_detail_context(request, collection, pending_item=None): @login_required def collection_list(request): - collections = visible_collections(request.user) - return render(request, 'core/collection_list.html', {'collections': collections}) + entries = [] + for collection in visible_collections(request.user).prefetch_related('filter_tags'): + count = collection_tag_items(collection).count() if collection.mode == Collection.Mode.TAGS else collection.sections.count() + entries.append({'collection': collection, 'section_count': count}) + return render(request, 'core/collection_list.html', {'entries': entries}) @login_required @@ -670,7 +684,7 @@ def collection_create(request): if form.is_valid(): visibility, team = collection_target_from_form(form, request.user) if visibility == Item.Visibility.TEAM and not team: - form.add_error('visibility', _('Select a team tag for a team collection.')) + form.add_error('team', _('Select a team for a team collection.')) else: collection = form.save(commit=False) collection.owner = request.user @@ -678,6 +692,8 @@ def collection_create(request): collection.team = team collection.save() form.save_m2m() + if collection.mode == Collection.Mode.MANUAL: + collection.filter_tags.clear() messages.success(request, _('Collection created.')) return redirect(collection) else: @@ -694,20 +710,26 @@ def collection_edit(request, pk): form = CollectionForm(request.POST, instance=collection, user=request.user) if form.is_valid(): visibility, team = collection_target_from_form(form, request.user) - incompatible = any(not collection_scope_allows_item( + target_mode = form.cleaned_data['mode'] + has_manual_sections = collection.mode == Collection.Mode.MANUAL and collection.sections.exists() + incompatible = target_mode == Collection.Mode.MANUAL and any(not collection_scope_allows_item( visibility, team.id if team else None, collection.owner_id, section.item.visibility, section.item.team_id, section.item.owner_id, ) for section in collection.sections.select_related('item')) - if incompatible: + if target_mode == Collection.Mode.TAGS and has_manual_sections: + form.add_error('mode', _('Remove all manual sections before changing to a tag collection.')) + elif incompatible: form.add_error(None, _('The collection visibility cannot change while it contains incompatible notes.')) elif visibility == Item.Visibility.TEAM and not team: - form.add_error('visibility', _('Select a team tag for a team collection.')) + form.add_error('team', _('Select a team for a team collection.')) else: collection = form.save(commit=False) collection.visibility = visibility collection.team = team collection.save() form.save_m2m() + if collection.mode == Collection.Mode.MANUAL: + collection.filter_tags.clear() messages.success(request, _('Collection saved.')) return redirect(collection) else: @@ -719,7 +741,8 @@ def collection_detail(request, pk): collection = get_object_or_404(visible_collections(request.user), pk=pk) return render(request, 'core/collection_detail.html', { 'collection': collection, - 'sections': collection.sections.select_related('item').prefetch_related('item__tags', 'item__attachments'), + 'sections': collection.sections.select_related('item').prefetch_related('item__tags', 'item__attachments') if collection.mode == Collection.Mode.MANUAL else [], + 'tag_items': collection_tag_items(collection) if collection.mode == Collection.Mode.TAGS else [], 'can_edit': user_can_edit_collection(request.user, collection), }) @@ -729,6 +752,8 @@ def collection_manage(request, pk): collection = get_object_or_404(visible_collections(request.user), pk=pk) if not user_can_edit_collection(request.user, collection): return JsonResponse({'error': 'forbidden'}, status=403) + if collection.mode == Collection.Mode.TAGS: + return redirect('collection_edit', pk=collection.pk) manage_url = reverse('collection_manage', args=[collection.pk]) if request.method == 'POST': action = request.POST.get('action') diff --git a/locale/de/LC_MESSAGES/django.po b/locale/de/LC_MESSAGES/django.po index 86ecfe5..67870f0 100644 --- a/locale/de/LC_MESSAGES/django.po +++ b/locale/de/LC_MESSAGES/django.po @@ -772,6 +772,50 @@ msgstr "Wähle höchstens ein Team aus." msgid "Combine existing notes into ordered documents." msgstr "Fasse bestehende Notizen zu geordneten Dokumenten zusammen." +#: core/models.py +msgid "Manual collection" +msgstr "Manuelle Sammlung" + +#: core/models.py core/templates/core/collection_detail.html core/templates/core/collection_list.html +msgid "Tag collection" +msgstr "Tag-Sammlung" + +#: core/forms.py +msgid "Collection type" +msgstr "Art der Sammlung" + +#: core/forms.py +msgid "Content tags" +msgstr "Inhalts-Tags" + +#: core/forms.py +msgid "Select at least one tag for a tag collection." +msgstr "Wähle mindestens einen Tag für eine Tag-Sammlung aus." + +#: core/templates/core/collection_form.html +msgid "Tag collections update automatically and are sorted by note creation time." +msgstr "Tag-Sammlungen aktualisieren sich automatisch und werden nach dem Erstellungszeitpunkt der Notizen sortiert." + +#: core/templates/core/collection_form.html +msgid "No content tags are available yet." +msgstr "Es sind noch keine Inhalts-Tags verfügbar." + +#: core/templates/core/collection_form.html +msgid "Notes must contain all selected tags. Team tags are not content filters." +msgstr "Notizen müssen alle ausgewählten Tags enthalten. Team-Tags sind keine Inhaltsfilter." + +#: core/templates/core/collection_detail.html +msgid "No notes match the selected tags yet." +msgstr "Noch keine Notizen entsprechen den ausgewählten Tags." + +#: core/views.py +msgid "Select a team for a team collection." +msgstr "Wähle für eine Team-Sammlung ein Team aus." + +#: core/views.py +msgid "Remove all manual sections before changing to a tag collection." +msgstr "Entferne alle manuellen Abschnitte, bevor du zu einer Tag-Sammlung wechselst." + #: core/templates/core/collection_list.html msgid "New collection" msgstr "Neue Sammlung"