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) @admin.register(Collection)
class CollectionAdmin(admin.ModelAdmin): class CollectionAdmin(admin.ModelAdmin):
list_display = ('title', 'visibility', 'team', 'owner', 'updated_at') list_display = ('title', 'mode', 'visibility', 'team', 'owner', 'updated_at')
list_filter = ('visibility', 'team') list_filter = ('mode', 'visibility', 'team')
filter_horizontal = ('filter_tags',)
inlines = [CollectionSectionInline] inlines = [CollectionSectionInline]
+25 -11
View File
@@ -1,6 +1,7 @@
from django import forms from django import forms
from django.contrib.auth.forms import UserCreationForm from django.contrib.auth.forms import UserCreationForm
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.db.models import Q
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from .models import ApiKey, Attachment, Collection, Item, Kanban, Tag, Team, TeamInvite, UserInvite, UserPreference 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): 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): def __init__(self, *args, user=None, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.fields['visibility'].choices = [
(Item.Visibility.PRIVATE, _('Private')),
(Item.Visibility.PUBLIC, _('Public')),
]
if user and user.is_authenticated: if user and user.is_authenticated:
team_slugs = user.team_memberships.values_list('team__slug', flat=True) team_ids = user.team_memberships.values_list('team_id', flat=True)
self.fields['tags'].queryset = Tag.objects.filter(name__in=team_slugs).order_by('name') 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: 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): def clean(self):
tags = self.cleaned_data['tags'] cleaned = super().clean()
if tags.count() > 1: if cleaned.get('mode') == Collection.Mode.TAGS and not cleaned.get('filter_tags'):
raise forms.ValidationError(_('Select at most one team.')) self.add_error('filter_tags', _('Select at least one tag for a tag collection.'))
return tags return cleaned
class Meta: class Meta:
model = Collection model = Collection
fields = ['title', 'description', 'visibility', 'tags'] fields = ['title', 'description', 'visibility', 'mode', 'team', 'filter_tags']
labels = { labels = {
'title': _('Title'), 'title': _('Title'),
'description': _('Description'), 'description': _('Description'),
'visibility': _('Visibility'), 'visibility': _('Visibility'),
'tags': _('Team'), 'mode': _('Collection type'),
'filter_tags': _('Content tags'),
} }
widgets = { widgets = {
'title': forms.TextInput(attrs={'class': 'form-control'}), 'title': forms.TextInput(attrs={'class': 'form-control'}),
'description': forms.Textarea(attrs={'class': 'form-control', 'rows': 4}), 'description': forms.Textarea(attrs={'class': 'form-control', 'rows': 4}),
'visibility': forms.Select(attrs={'class': 'form-select'}), '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): def delete_unused_tags(tags=None):
"""Delete tags that are no longer used by items or tag-based features.""" """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 = 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), name__in=Team.objects.values_list('slug', flat=True),
).delete() ).delete()
@@ -196,12 +196,17 @@ class Kanban(models.Model):
class Collection(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') 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')
title = models.CharField(max_length=200) title = models.CharField(max_length=200)
description = models.TextField(blank=True) description = models.TextField(blank=True)
visibility = models.CharField(max_length=10, choices=Item.Visibility.choices, default=Item.Visibility.PRIVATE) 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) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=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> <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.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.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>
</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> </div>
{% if collection.description %}<div class="content lead mt-4">{{ collection.description|markdown }}</div>{% endif %} {% if collection.description %}<div class="content lead mt-4">{{ collection.description|markdown }}</div>{% endif %}
</header> </header>
{% if sections %} {% if sections or tag_items %}
<nav class="collection-toc mb-5" aria-label="{% trans 'Contents' %}"> <nav class="collection-toc mb-5" aria-label="{% trans 'Contents' %}">
<div class="small fw-semibold text-uppercase text-muted mb-2">{% trans "Contents" %}</div> <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> </nav>
{% endif %} {% endif %}
<div class="collection-flow content"> <div class="collection-flow content">
{% for section in sections %} {% if collection.mode == 'tags' %}
<section id="section-{{ section.id }}" class="collection-flow-section"> {% 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 %}
{% if section.title %}<h2>{{ section.title }}</h2>{% endif %} {% else %}
{{ section.item|item_markdown }} {% 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 section.item.comment %}<div class="text-muted border-start ps-3 mt-3">{{ section.item.comment|markdown }}</div>{% endif %} {% endif %}
</section>
{% empty %}<div class="text-center text-muted py-5">{% trans "This collection has no sections yet." %}</div>{% endfor %}
</div> </div>
</article> </article>
{% endblock %} {% 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-md-none alert alert-info">{% trans "Collections are currently available on desktop only." %}</div>
<div class="d-none d-md-block"> <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> <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> <h1 class="h3 mb-0">{{ heading }}</h1>
{% csrf_token %} {% csrf_token %}
{% if form.non_field_errors %}<div class="alert alert-danger mb-0">{{ form.non_field_errors }}</div>{% endif %} {% 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.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.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.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.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.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 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></form>
</div> </div>
+5 -4
View File
@@ -8,13 +8,14 @@
<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="row g-3"> <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="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 %} {% 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> </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>
</div> </div>
{% endblock %} {% endblock %}
+1 -1
View File
@@ -9,7 +9,7 @@
<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.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.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>
</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 %} {% 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): def test_create_collection_and_add_compatible_note(self):
response = self.client.post(reverse('collection_create'), { 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() collection = Collection.objects.get()
self.assertRedirects(response, collection.get_absolute_url()) self.assertRedirects(response, collection.get_absolute_url())
@@ -199,12 +199,12 @@ class CollectionTests(TestCase):
self.assertEqual(response.status_code, 409) self.assertEqual(response.status_code, 409)
self.assertTrue(Item.objects.filter(pk=note.id).exists()) 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) team = Team.objects.create(name='Docs team', slug='docs', owner=self.user)
TeamMembership.objects.create(team=team, user=self.user, role=TeamMembership.Role.OWNER) 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'), { 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() collection = Collection.objects.get()
self.assertRedirects(response, collection.get_absolute_url()) self.assertRedirects(response, collection.get_absolute_url())
@@ -218,13 +218,36 @@ class CollectionTests(TestCase):
response = self.client.get(reverse('collection_manage', args=[collection.id])) response = self.client.get(reverse('collection_manage', args=[collection.id]))
self.assertNotContains(response, f'name="item_id" value="{public_note.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) team = Team.objects.create(name='Docs team', slug='docs', owner=self.user)
TeamMembership.objects.create(team=team, user=self.user, role=TeamMembership.Role.OWNER) TeamMembership.objects.create(team=team, user=self.user, role=TeamMembership.Role.OWNER)
Tag.objects.create(name='docs') 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')) 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): def test_collection_opens_as_continuous_reading_view(self):
collection = Collection.objects.create(owner=self.user, title='Reading view') 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): def test_collection_tag_is_not_removed_as_unused(self):
tag = Tag.objects.create(name='documentation') tag = Tag.objects.create(name='documentation')
collection = Collection.objects.create(owner=self.user, title='Tagged') collection = Collection.objects.create(owner=self.user, title='Tagged', mode=Collection.Mode.TAGS)
collection.tags.add(tag) collection.filter_tags.add(tag)
note = Item.objects.create(owner=self.user, content='#temporary') note = Item.objects.create(owner=self.user, content='#temporary')
note.sync_metadata() note.sync_metadata()
note.content = '' note.content = ''
+38 -13
View File
@@ -536,7 +536,7 @@ def team_accept_invite(request, token):
def visible_collections(user): 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: 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(Q(owner=user) | Q(team_id__in=user_team_ids(user)) | Q(visibility=Item.Visibility.PUBLIC)).distinct()
return qs.filter(visibility=Item.Visibility.PUBLIC) 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): def collection_target_from_form(form, user):
tags = form.cleaned_data.get('tags') team = form.cleaned_data.get('team')
tag_names = list(tags.values_list('name', flat=True)) if tags is not None else [] if team:
membership = TeamMembership.objects.select_related('team').filter(user=user, team__slug__in=tag_names).first() return Item.Visibility.TEAM, team
if membership:
return Item.Visibility.TEAM, membership.team
return form.cleaned_data['visibility'], None 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): def item_can_change_for_collection(user, item, collection):
if not user_can_edit_item(user, item): if not user_can_edit_item(user, item):
return False return False
@@ -659,8 +670,11 @@ def collection_detail_context(request, collection, pending_item=None):
@login_required @login_required
def collection_list(request): def collection_list(request):
collections = visible_collections(request.user) entries = []
return render(request, 'core/collection_list.html', {'collections': collections}) 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 @login_required
@@ -670,7 +684,7 @@ def collection_create(request):
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)
if visibility == Item.Visibility.TEAM and not team: 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: else:
collection = form.save(commit=False) collection = form.save(commit=False)
collection.owner = request.user collection.owner = request.user
@@ -678,6 +692,8 @@ 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:
collection.filter_tags.clear()
messages.success(request, _('Collection created.')) messages.success(request, _('Collection created.'))
return redirect(collection) return redirect(collection)
else: else:
@@ -694,20 +710,26 @@ def collection_edit(request, pk):
form = CollectionForm(request.POST, instance=collection, user=request.user) form = CollectionForm(request.POST, instance=collection, user=request.user)
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)
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, 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,
) for section in collection.sections.select_related('item')) ) 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.')) form.add_error(None, _('The collection visibility cannot change while it contains incompatible notes.'))
elif visibility == Item.Visibility.TEAM and not team: 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: else:
collection = form.save(commit=False) collection = form.save(commit=False)
collection.visibility = visibility collection.visibility = visibility
collection.team = team collection.team = team
collection.save() collection.save()
form.save_m2m() form.save_m2m()
if collection.mode == Collection.Mode.MANUAL:
collection.filter_tags.clear()
messages.success(request, _('Collection saved.')) messages.success(request, _('Collection saved.'))
return redirect(collection) return redirect(collection)
else: else:
@@ -719,7 +741,8 @@ 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').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), '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) collection = get_object_or_404(visible_collections(request.user), pk=pk)
if not user_can_edit_collection(request.user, collection): if not user_can_edit_collection(request.user, collection):
return JsonResponse({'error': 'forbidden'}, status=403) 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]) manage_url = reverse('collection_manage', args=[collection.pk])
if request.method == 'POST': if request.method == 'POST':
action = request.POST.get('action') 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." msgid "Combine existing notes into ordered documents."
msgstr "Fasse bestehende Notizen zu geordneten Dokumenten zusammen." 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 #: core/templates/core/collection_list.html
msgid "New collection" msgid "New collection"
msgstr "Neue Sammlung" msgstr "Neue Sammlung"