Add automatic tag collections
This commit is contained in:
+3
-2
@@ -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
@@ -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
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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')
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user