Add automatic tag collections

This commit is contained in:
rucki
2026-08-27 16:48:56 +02:00
parent 2bcd9d901a
commit 682f5f7442
11 changed files with 197 additions and 56 deletions
+3 -2
View File
@@ -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]
+25 -11
View File
@@ -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(),
}
@@ -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),
),
]
+7 -2
View File
@@ -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)
+10 -11
View File
@@ -9,30 +9,29 @@
<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>
<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.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.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>
{% if can_edit %}<a class="btn btn-dark" href="{% url 'collection_manage' collection.id %}">{% trans "Edit collection" %}</a>{% endif %}
{% if can_edit %}<a class="btn btn-dark" href="{% if collection.mode == 'tags' %}{% url 'collection_edit' collection.id %}{% else %}{% url 'collection_manage' collection.id %}{% endif %}">{% trans "Edit collection" %}</a>{% endif %}
</div>
{% if collection.description %}<div class="content lead mt-4">{{ collection.description|markdown }}</div>{% endif %}
</header>
{% if sections %}
{% if sections or tag_items %}
<nav class="collection-toc mb-5" aria-label="{% trans 'Contents' %}">
<div class="small fw-semibold text-uppercase text-muted mb-2">{% trans "Contents" %}</div>
<ol class="mb-0">{% for section in sections %}<li><a href="#section-{{ section.id }}">{{ section.title|default:section.item.content|truncatechars:90 }}</a></li>{% endfor %}</ol>
<ol class="mb-0">{% if collection.mode == 'tags' %}{% for item in tag_items %}<li><a href="#item-{{ item.id }}">{{ item.content|truncatechars:90 }}</a></li>{% endfor %}{% else %}{% for section in sections %}<li><a href="#section-{{ section.id }}">{{ section.title|default:section.item.content|truncatechars:90 }}</a></li>{% endfor %}{% endif %}</ol>
</nav>
{% endif %}
<div class="collection-flow content">
{% for section in sections %}
<section id="section-{{ section.id }}" class="collection-flow-section">
{% if section.title %}<h2>{{ section.title }}</h2>{% endif %}
{{ section.item|item_markdown }}
{% if section.item.comment %}<div class="text-muted border-start ps-3 mt-3">{{ section.item.comment|markdown }}</div>{% endif %}
</section>
{% empty %}<div class="text-center text-muted py-5">{% trans "This collection has no sections yet." %}</div>{% endfor %}
{% if collection.mode == 'tags' %}
{% for item in tag_items %}<section id="item-{{ item.id }}" class="collection-flow-section">{{ item|item_markdown }}{% if item.comment %}<div class="text-muted border-start ps-3 mt-3">{{ item.comment|markdown }}</div>{% endif %}</section>{% empty %}<div class="text-center text-muted py-5">{% trans "No notes match the selected tags yet." %}</div>{% endfor %}
{% else %}
{% for section in sections %}<section id="section-{{ section.id }}" class="collection-flow-section">{% if section.title %}<h2>{{ section.title }}</h2>{% endif %}{{ section.item|item_markdown }}{% if section.item.comment %}<div class="text-muted border-start ps-3 mt-3">{{ section.item.comment|markdown }}</div>{% endif %}</section>{% empty %}<div class="text-center text-muted py-5">{% trans "This collection has no sections yet." %}</div>{% endfor %}
{% endif %}
</div>
</article>
{% endblock %}
+5 -3
View File
@@ -4,14 +4,16 @@
<div class="d-md-none alert alert-info">{% trans "Collections are currently available on desktop only." %}</div>
<div class="d-none d-md-block">
<a class="btn btn-sm btn-outline-secondary mb-3" href="{% if collection %}{{ collection.get_absolute_url }}{% else %}{% url 'collection_list' %}{% endif %}">← {% trans "back" %}</a>
<form method="post" class="card item-card"><div class="card-body vstack gap-3">
<form method="post" class="card item-card" x-data="{mode:'{{ form.mode.value|default:'manual'|escapejs }}'}"><div class="card-body vstack gap-3">
<h1 class="h3 mb-0">{{ heading }}</h1>
{% csrf_token %}
{% if form.non_field_errors %}<div class="alert alert-danger mb-0">{{ form.non_field_errors }}</div>{% endif %}
<div><label class="form-label">{{ form.title.label }}</label>{{ form.title }}{{ form.title.errors }}</div>
<div><label class="form-label">{{ form.description.label }}</label>{{ form.description }}{{ form.description.errors }}</div>
<div><label class="form-label">{{ form.visibility.label }}</label>{{ form.visibility }}{{ form.visibility.errors }}<div class="form-text">{% trans "Optional. Selecting a team makes the collection visible and editable for that team." %}</div></div>
<div><label class="form-label">{{ form.tags.label }}</label><div class="border rounded p-3 collection-tags">{% if form.tags.field.queryset.exists %}{{ form.tags }}{% else %}<span class="text-muted small">{% trans "You do not belong to a team." %}</span>{% endif %}</div>{{ form.tags.errors }}</div>
<div><label class="form-label">{{ form.mode.label }}</label>{{ form.mode }}{{ form.mode.errors }}<div class="form-text">{% trans "Tag collections update automatically and are sorted by note creation time." %}</div></div>
<div><label class="form-label">{{ form.visibility.label }}</label>{{ form.visibility }}{{ form.visibility.errors }}</div>
<div><label class="form-label">{{ form.team.label }}</label>{{ form.team }}{{ form.team.errors }}<div class="form-text">{% trans "Optional. Selecting a team makes the collection visible and editable for that team." %}</div></div>
<div x-show="mode === 'tags'"><label class="form-label">{{ form.filter_tags.label }}</label><div class="border rounded p-3 collection-tags">{% if form.filter_tags.field.queryset.exists %}{{ form.filter_tags }}{% else %}<span class="text-muted small">{% trans "No content tags are available yet." %}</span>{% endif %}</div>{{ form.filter_tags.errors }}<div class="form-text">{% trans "Notes must contain all selected tags. Team tags are not content filters." %}</div></div>
<div class="d-flex gap-2"><button class="btn btn-dark">{% trans "Save" %}</button><a class="btn btn-outline-secondary" href="{% if collection %}{{ collection.get_absolute_url }}{% else %}{% url 'collection_list' %}{% endif %}">{% trans "Cancel" %}</a></div>
</div></form>
</div>
+5 -4
View File
@@ -8,13 +8,14 @@
<a class="btn btn-dark" href="{% url 'collection_create' %}">{% trans "New collection" %}</a>
</div>
<div class="row g-3">
{% for collection in collections %}
{% 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="d-flex justify-content-between gap-2"><h2 class="h5 mb-1">{{ collection.title }}</h2>{% 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 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>
{% if collection.description %}<p class="text-muted mt-2 mb-2">{{ collection.description|truncatechars:140 }}</p>{% endif %}
<div class="small text-muted">{{ collection.sections.count }} {% trans "sections" %} · {{ collection.updated_at|date:'d.m.Y H:i' }}</div>
{% 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></a></div>
{% empty %}<div class="col-12 text-center text-muted py-5">{% trans "No collections yet." %}</div>{% endfor %}
{% endwith %}{% empty %}<div class="col-12 text-center text-muted py-5">{% trans "No collections yet." %}</div>{% endfor %}
</div>
</div>
{% endblock %}
+1 -1
View File
@@ -9,7 +9,7 @@
<h1 class="display-6 mb-2">{{ collection.title }}</h1>
<div class="d-flex flex-wrap gap-2 align-items-center">
{% 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.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>
{% if can_edit %}<div class="d-flex gap-2"><a class="btn btn-outline-secondary" href="{% url 'collection_edit' collection.id %}">{% trans "Collection settings" %}</a>{% if can_delete %}<form method="post" action="{% url 'collection_delete' collection.id %}" onsubmit="return confirm('{% trans "Delete collection? The notes remain available." %}')">{% csrf_token %}<button class="btn btn-outline-danger">{% trans "Delete" %}</button></form>{% endif %}</div>{% endif %}
+32 -9
View File
@@ -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 = ''
+38 -13
View File
@@ -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')
+44
View File
@@ -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"