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