Add desktop note collections

This commit is contained in:
rucki
2026-08-27 14:06:06 +02:00
parent 4b83582e72
commit cd97a6cb62
14 changed files with 731 additions and 10 deletions
+13 -1
View File
@@ -1,5 +1,17 @@
from django.contrib import admin
from .models import ApiKey, Attachment, Item, Kanban, Tag, Team, TeamInvite, TeamMembership, UserInvite, UserPreference
from .models import ApiKey, Attachment, Collection, CollectionSection, Item, Kanban, Tag, Team, TeamInvite, TeamMembership, UserInvite, UserPreference
class CollectionSectionInline(admin.TabularInline):
model = CollectionSection
extra = 0
@admin.register(Collection)
class CollectionAdmin(admin.ModelAdmin):
list_display = ('title', 'visibility', 'team', 'owner', 'updated_at')
list_filter = ('visibility', 'team')
inlines = [CollectionSectionInline]
class AttachmentInline(admin.TabularInline):
+19 -1
View File
@@ -2,7 +2,7 @@ from django import forms
from django.contrib.auth.forms import UserCreationForm
from django.contrib.auth.models import User
from django.utils.translation import gettext_lazy as _
from .models import ApiKey, Attachment, Item, Kanban, Tag, Team, TeamInvite, UserInvite, UserPreference
from .models import ApiKey, Attachment, Collection, Item, Kanban, Tag, Team, TeamInvite, UserInvite, UserPreference
class MultipleFileInput(forms.ClearableFileInput):
@@ -133,6 +133,24 @@ class KanbanForm(forms.ModelForm):
return super().save(commit=commit)
class CollectionForm(forms.ModelForm):
class Meta:
model = Collection
fields = ['title', 'description', 'visibility', 'tags']
labels = {
'title': _('Title'),
'description': _('Description'),
'visibility': _('Visibility'),
'tags': _('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(),
}
class ApiKeyForm(forms.ModelForm):
class Meta:
model = ApiKey
@@ -0,0 +1,48 @@
# Generated by Django 5.2.17 on 2026-08-27 12:01
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0016_userpreference_default_item_kind'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Collection',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=200)),
('description', models.TextField(blank=True)),
('visibility', models.CharField(choices=[('private', 'Private'), ('team', 'Team'), ('public', 'Public')], default='private', max_length=10)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='collections', to=settings.AUTH_USER_MODEL)),
('tags', models.ManyToManyField(blank=True, related_name='collections', to='core.tag')),
('team', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='collections', to='core.team')),
],
options={
'ordering': ['-updated_at'],
},
),
migrations.CreateModel(
name='CollectionSection',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(blank=True, max_length=200)),
('position', models.PositiveIntegerField(default=0)),
('created_at', models.DateTimeField(auto_now_add=True)),
('collection', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sections', to='core.collection')),
('item', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='collection_sections', to='core.item')),
],
options={
'ordering': ['position', 'id'],
'constraints': [models.UniqueConstraint(fields=('collection', 'item'), name='unique_collection_item'), models.UniqueConstraint(fields=('collection', 'position'), name='unique_collection_position')],
},
),
]
+39 -1
View File
@@ -35,7 +35,7 @@ def without_markdown_code(text):
def delete_unused_tags(tags=None):
"""Delete tags that are no longer used by items or tag-based features."""
candidates = Tag.objects.all() if tags is None else Tag.objects.filter(pk__in=[tag.pk for tag in tags])
candidates.filter(items__isnull=True, kanbans__isnull=True, api_keys__isnull=True).exclude(
candidates.filter(items__isnull=True, kanbans__isnull=True, api_keys__isnull=True, collections__isnull=True).exclude(
name__in=Team.objects.values_list('slug', flat=True),
).delete()
@@ -195,6 +195,44 @@ class Kanban(models.Model):
return self.name
class Collection(models.Model):
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')
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ['-updated_at']
def __str__(self):
return self.title
def get_absolute_url(self):
return reverse('collection_detail', args=[self.pk])
class CollectionSection(models.Model):
collection = models.ForeignKey(Collection, on_delete=models.CASCADE, related_name='sections')
item = models.ForeignKey(Item, on_delete=models.PROTECT, related_name='collection_sections')
title = models.CharField(max_length=200, blank=True)
position = models.PositiveIntegerField(default=0)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['position', 'id']
constraints = [
models.UniqueConstraint(fields=['collection', 'item'], name='unique_collection_item'),
models.UniqueConstraint(fields=['collection', 'position'], name='unique_collection_position'),
]
def __str__(self):
return f'{self.collection}: {self.title or self.item}'
class UserPreference(models.Model):
user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='preferences')
language = models.CharField(max_length=10, blank=True, default='', choices=[('', _('Browser default')), ('de', 'Deutsch'), ('en', 'English')])
+1
View File
@@ -2,6 +2,7 @@
<form id="item-{{ item.id }}" class="card item-card shadow-sm position-relative" method="post" enctype="multipart/form-data" hx-post="{% url 'edit_item' item.id %}{% if request.GET.urlencode %}?{{ request.GET.urlencode }}{% endif %}" hx-target="#item-{{ item.id }}" hx-swap="outerHTML" x-data="quickComposer([{% for tag in tags %}'{{ tag.name|escapejs }}'{% if not forloop.last %},{% endif %}{% endfor %}], {{ item.id }})" x-init="text=$el.querySelector('textarea').value">
{% csrf_token %}
<div class="card-body vstack gap-2">
{% if form.errors %}<div class="alert alert-danger mb-0">{{ form.errors }}</div>{% endif %}
<div class="small text-muted">{% trans "Type:" %} {{ item.get_kind_display }}</div>
<div class="mobile-tag-picker">
<div class="small text-muted mb-1">{% trans "Add tags" %}</div>
+1 -1
View File
@@ -26,7 +26,7 @@
<div class="d-none d-md-flex align-items-center gap-2">
<a class="small nav-soft fw-semibold" href="/">{% for label in nav_overview_labels %}{{ label }}{% if not forloop.last %} · {% endif %}{% endfor %}</a>
{% for link in nav_hidden_links %}<a class="small nav-soft" href="{{ link.url }}">{{ link.label }}</a>{% endfor %}
{% if user.is_authenticated %}<a class="small nav-soft" href="{% url 'journal' %}">{% trans "Journal" %}</a><a class="small nav-soft" href="{% url 'kanban_list' %}">{% trans "Kanban" %}</a><a class="small nav-soft" href="{% url 'team_list' %}">{% trans "Teams" %}</a>{% endif %}
{% if user.is_authenticated %}<a class="small nav-soft" href="{% url 'journal' %}">{% trans "Journal" %}</a><a class="small nav-soft" href="{% url 'kanban_list' %}">{% trans "Kanban" %}</a><a class="small nav-soft" href="{% url 'collection_list' %}">{% trans "Collections" %}</a><a class="small nav-soft" href="{% url 'team_list' %}">{% trans "Teams" %}</a>{% endif %}
</div>
<div class="d-flex d-md-none align-items-center gap-1"><a class="small nav-soft fw-semibold" href="/">Start</a>{% if user.is_authenticated %}<a class="small nav-soft" href="{% url 'journal' %}">{% trans "Journal" %}</a><a class="small nav-soft" href="{% url 'team_list' %}">{% trans "Teams" %}</a>{% endif %}</div>
{% if user.is_authenticated %}
@@ -0,0 +1,67 @@
{% extends 'core/base.html' %}
{% load i18n markdown_extras %}
{% block content %}
<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-flex justify-content-between align-items-start gap-3 mb-4">
<div>
<a class="btn btn-sm btn-outline-secondary mb-3" href="{% url 'collection_list' %}">← {% trans "Collections" %}</a>
<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 %}
</div>
</div>
{% if can_edit %}<div class="d-flex gap-2"><a class="btn btn-outline-secondary" href="{% url 'collection_edit' collection.id %}">{% trans "Edit collection" %}</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 %}
</div>
{% if collection.description %}<div class="content lead mb-4">{{ collection.description|markdown }}</div>{% endif %}
{% if sections %}
<div class="card item-card mb-4"><div class="card-body"><div class="fw-semibold 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></div></div>
{% endif %}
{% if pending_item %}
<div class="alert alert-warning">
<h2 class="h5">{% trans "The note has a different visibility" %}</h2>
<p>{% trans "To add it, change its visibility or create a separate copy for this collection." %}</p>
<div class="border rounded bg-white p-2 mb-3">{{ pending_item.content|truncatechars:180 }}</div>
<div class="d-flex gap-2">
{% if pending_can_convert %}<form method="post">{% csrf_token %}<input type="hidden" name="action" value="confirm_add"><input type="hidden" name="item_id" value="{{ pending_item.id }}"><input type="hidden" name="mode" value="convert"><button class="btn btn-warning">{% if collection.visibility == 'public' %}{% trans "Make note public" %}{% elif collection.team %}{% trans "Share note with team" %}{% else %}{% trans "Make note private" %}{% endif %}</button></form>{% endif %}
<form method="post">{% csrf_token %}<input type="hidden" name="action" value="confirm_add"><input type="hidden" name="item_id" value="{{ pending_item.id }}"><input type="hidden" name="mode" value="copy"><button class="btn btn-outline-secondary">{% trans "Create compatible copy" %}</button></form>
<a class="btn btn-link" href="{{ collection.get_absolute_url }}">{% trans "Cancel" %}</a>
</div>
{% if not pending_can_convert %}<div class="small text-muted mt-2">{% trans "The original cannot be changed because it is used elsewhere or you cannot edit it." %}</div>{% endif %}
</div>
{% endif %}
<div class="vstack gap-4 mb-5">
{% for section in sections %}
<section id="section-{{ section.id }}" class="card item-card"><div class="card-body">
<div class="d-flex justify-content-between align-items-start gap-3 mb-3">
<h2 class="h3 mb-0">{{ section.title|default:section.item.content|truncatechars:100 }}</h2>
<a class="small text-muted" href="{{ section.item.get_absolute_url }}">#{{ section.item.id }}</a>
</div>
<div class="content">{{ section.item|item_markdown }}</div>
{% if section.item.comment %}<div class="content text-muted border-start ps-3 mt-3">{{ section.item.comment|markdown }}</div>{% endif %}
{% if can_edit %}<div class="border-top mt-4 pt-3 d-flex align-items-center gap-2">
<form method="post" class="d-flex gap-2 flex-grow-1">{% csrf_token %}<input type="hidden" name="action" value="title"><input type="hidden" name="section_id" value="{{ section.id }}"><input class="form-control form-control-sm" name="title" value="{{ section.title }}" placeholder="{% trans 'Optional section title' %}"><button class="btn btn-sm btn-outline-secondary">{% trans "Save title" %}</button></form>
<form method="post">{% csrf_token %}<input type="hidden" name="action" value="up"><input type="hidden" name="section_id" value="{{ section.id }}"><button class="btn btn-sm btn-outline-secondary" title="{% trans 'Move up' %}">↑</button></form>
<form method="post">{% csrf_token %}<input type="hidden" name="action" value="down"><input type="hidden" name="section_id" value="{{ section.id }}"><button class="btn btn-sm btn-outline-secondary" title="{% trans 'Move down' %}">↓</button></form>
<form method="post">{% csrf_token %}<input type="hidden" name="action" value="remove"><input type="hidden" name="section_id" value="{{ section.id }}"><button class="btn btn-sm btn-outline-danger">{% trans "Remove" %}</button></form>
</div>{% endif %}
</div></section>
{% empty %}<div class="text-center text-muted py-5">{% trans "This collection has no sections yet." %}</div>{% endfor %}
</div>
{% if can_edit %}
<div class="card item-card"><div class="card-body">
<h2 class="h4">{% trans "Add existing note" %}</h2>
<form method="get" class="input-group mb-3"><input class="form-control" name="q" value="{{ q }}" placeholder="{% trans 'Search notes' %}"><button class="btn btn-outline-secondary">{% trans "Search" %}</button></form>
<div class="vstack gap-2">
{% for item in candidates %}<div class="border rounded p-3 d-flex justify-content-between align-items-start gap-3"><div><div>{{ item.content|truncatechars:180 }}</div><div class="small text-muted">#{{ item.id }} · {{ item.get_visibility_display }}{% if item.team %} · {{ item.team.name }}{% endif %}</div></div><form method="post">{% csrf_token %}<input type="hidden" name="action" value="add"><input type="hidden" name="item_id" value="{{ item.id }}"><button class="btn btn-sm btn-dark">{% trans "Add" %}</button></form></div>{% empty %}<div class="text-muted">{% trans "No matching notes." %}</div>{% endfor %}
</div>
</div></div>
{% endif %}
</div>
{% endblock %}
+18
View File
@@ -0,0 +1,18 @@
{% extends 'core/base.html' %}
{% load i18n %}
{% block content %}
<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">
<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 "Adding one of your team tags turns this into a team collection." %}</div></div>
<div><label class="form-label">{{ form.tags.label }}</label><div class="border rounded p-3 collection-tags">{{ form.tags }}</div>{{ form.tags.errors }}</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>
{% endblock %}
+20
View File
@@ -0,0 +1,20 @@
{% extends 'core/base.html' %}
{% load i18n %}
{% block content %}
<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-flex justify-content-between align-items-center mb-4">
<div><h1 class="h3 mb-1">{% trans "Collections" %}</h1><p class="text-muted mb-0">{% trans "Combine existing notes into ordered documents." %}</p></div>
<a class="btn btn-dark" href="{% url 'collection_create' %}">{% trans "New collection" %}</a>
</div>
<div class="row g-3">
{% for collection in collections %}
<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>
{% 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>
</div></a></div>
{% empty %}<div class="col-12 text-center text-muted py-5">{% trans "No collections yet." %}</div>{% endfor %}
</div>
</div>
{% endblock %}
+1
View File
@@ -4,6 +4,7 @@
<form class="card item-card shadow-sm position-relative" method="post" enctype="multipart/form-data" x-data="quickComposer([{% for tag in tags %}'{{ tag.name|escapejs }}'{% if not forloop.last %},{% endif %}{% endfor %}], {{ item.id }})" x-init="text=$el.querySelector('textarea[name=content]').value">
{% csrf_token %}
<div class="card-body vstack gap-3">
{% if form.errors %}<div class="alert alert-danger mb-0">{{ form.errors }}</div>{% endif %}
<div class="d-flex justify-content-between align-items-center gap-2 sticky-top bg-white py-2" style="z-index:5">
<h1 class="h5 m-0">{% blocktrans with id=item.id %}Edit item #{{ id }}{% endblocktrans %}</h1>
<div class="d-flex gap-2"><button class="btn btn-success px-3 py-2" title="{% trans 'Save' %}">✓</button><a class="btn btn-danger px-3 py-2" href="{{ next_url }}" title="{% trans 'Cancel' %}">×</a></div>
+82 -1
View File
@@ -2,7 +2,7 @@ from django.contrib.auth import get_user_model
from django.test import TestCase
from django.urls import reverse
from .models import Item, Tag, UserPreference
from .models import Collection, CollectionSection, Item, Tag, Team, TeamMembership, UserPreference
from .templatetags.markdown_extras import markdown
from .views import parse_due_command, parse_quick_content
@@ -147,6 +147,87 @@ class NavigationAndLayoutTests(TestCase):
self.assertLess(toolbar_position, file_position)
class CollectionTests(TestCase):
def setUp(self):
self.user = get_user_model().objects.create_user(username='collector', password='secret')
self.client.force_login(self.user)
def test_create_collection_and_add_compatible_note(self):
response = self.client.post(reverse('collection_create'), {
'title': 'Documentation', 'description': 'Guide', 'visibility': Item.Visibility.PRIVATE,
})
collection = Collection.objects.get()
self.assertRedirects(response, collection.get_absolute_url())
note = Item.objects.create(owner=self.user, kind=Item.Kind.NOTE, content='Introduction')
response = self.client.post(collection.get_absolute_url(), {'action': 'add', 'item_id': note.id})
self.assertRedirects(response, collection.get_absolute_url())
self.assertTrue(CollectionSection.objects.filter(collection=collection, item=note).exists())
def test_public_collection_asks_before_copying_private_note(self):
collection = Collection.objects.create(owner=self.user, title='Public guide', visibility=Item.Visibility.PUBLIC)
note = Item.objects.create(owner=self.user, kind=Item.Kind.NOTE, content='Private section')
response = self.client.post(collection.get_absolute_url(), {'action': 'add', 'item_id': note.id})
self.assertContains(response, 'name="mode" value="copy"')
response = self.client.post(collection.get_absolute_url(), {
'action': 'confirm_add', 'item_id': note.id, 'mode': 'copy',
})
self.assertRedirects(response, collection.get_absolute_url())
copied = collection.sections.get().item
self.assertNotEqual(copied.id, note.id)
self.assertEqual(copied.visibility, Item.Visibility.PUBLIC)
note.refresh_from_db()
self.assertEqual(note.visibility, Item.Visibility.PRIVATE)
def test_note_used_by_collection_cannot_be_deleted(self):
collection = Collection.objects.create(owner=self.user, title='Protected')
note = Item.objects.create(owner=self.user, kind=Item.Kind.NOTE, content='Do not delete')
CollectionSection.objects.create(collection=collection, item=note, position=0)
response = self.client.post(reverse('delete_item', args=[note.id]))
self.assertEqual(response.status_code, 409)
self.assertTrue(Item.objects.filter(pk=note.id).exists())
def test_team_tag_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],
})
collection = Collection.objects.get()
self.assertRedirects(response, collection.get_absolute_url())
self.assertEqual(collection.team, team)
self.assertEqual(collection.visibility, Item.Visibility.TEAM)
def test_public_collection_is_visible_without_login(self):
collection = Collection.objects.create(owner=self.user, title='Public', visibility=Item.Visibility.PUBLIC)
self.client.logout()
self.assertEqual(self.client.get(collection.get_absolute_url()).status_code, 200)
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)
note = Item.objects.create(owner=self.user, content='#temporary')
note.sync_metadata()
note.content = ''
note.save()
note.sync_metadata()
self.assertTrue(Tag.objects.filter(pk=tag.pk).exists())
def test_api_cannot_make_collection_note_incompatible(self):
collection = Collection.objects.create(owner=self.user, title='Public', visibility=Item.Visibility.PUBLIC)
note = Item.objects.create(owner=self.user, kind=Item.Kind.NOTE, content='Published', visibility=Item.Visibility.PUBLIC)
CollectionSection.objects.create(collection=collection, item=note, position=0)
response = self.client.patch(
reverse('api_item_detail', args=[note.id]),
data='{"visibility":"private"}', content_type='application/json',
)
self.assertEqual(response.status_code, 409)
note.refresh_from_db()
self.assertEqual(note.visibility, Item.Visibility.PUBLIC)
class CopyButtonTests(TestCase):
def test_item_has_copy_button_with_original_text(self):
user = get_user_model().objects.create_user(username='tester', password='secret')
+5
View File
@@ -19,6 +19,11 @@ urlpatterns = [
path('teams/', views.team_list, name='team_list'),
path('teams/<int:pk>/', views.team_detail, name='team_detail'),
path('teams/invite/<str:token>/', views.team_accept_invite, name='team_accept_invite'),
path('collections/', views.collection_list, name='collection_list'),
path('collections/new/', views.collection_create, name='collection_create'),
path('collections/<int:pk>/', views.collection_detail, name='collection_detail'),
path('collections/<int:pk>/edit/', views.collection_edit, name='collection_edit'),
path('collections/<int:pk>/delete/', views.collection_delete, name='collection_delete'),
path('items/<int:pk>/', views.item_detail, name='item_detail'),
path('items/<int:pk>/card/', views.item_card, name='item_card'),
path('items/<int:pk>/edit/', views.edit_item, name='edit_item'),
+261 -5
View File
@@ -9,7 +9,8 @@ from django.conf import settings
from django.contrib import messages
from django.contrib.auth import login
from django.contrib.auth.decorators import login_required
from django.db.models import Case, IntegerField, Q, Value, When
from django.db import transaction
from django.db.models import Case, IntegerField, ProtectedError, Q, Value, When
from django.http import HttpResponse, JsonResponse
from django.urls import reverse
from django.utils import timezone
@@ -18,8 +19,8 @@ from django.utils.translation import gettext as _
from django.shortcuts import get_object_or_404, redirect, render
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods, require_POST
from .forms import ApiKeyForm, EditItemForm, InviteRegistrationForm, KanbanForm, QuickItemForm, TeamForm, TeamInviteForm, UserInviteForm, UserPreferenceForm, UserSettingsForm
from .models import ApiKey, Attachment, Item, Kanban, Tag, Team, TeamInvite, TeamMembership, UserInvite, UserPreference
from .forms import ApiKeyForm, CollectionForm, EditItemForm, InviteRegistrationForm, KanbanForm, QuickItemForm, TeamForm, TeamInviteForm, UserInviteForm, UserPreferenceForm, UserSettingsForm
from .models import ApiKey, Attachment, Collection, CollectionSection, Item, Kanban, Tag, Team, TeamInvite, TeamMembership, UserInvite, UserPreference
TYPE_COMMAND_RE = re.compile(r'^/(todo|note|link|journal)\b', re.I)
VISIBILITY_COMMAND_RE = re.compile(r'/(public|private)\b', re.I)
@@ -159,6 +160,22 @@ def user_can_delete_item(user, item):
return item.owner_id == user.id or (item.team_id and TeamMembership.objects.filter(team=item.team, user=user, role__in=[TeamMembership.Role.OWNER, TeamMembership.Role.ADMIN]).exists())
def desired_item_scope(item, user, force_private=False):
if force_private:
return Item.Visibility.PRIVATE, None
tag_names = {match.group(1).lower() for match in re.finditer(r'(?<!\w)#([\wäöüÄÖÜß-]+)', item.content)}
membership = TeamMembership.objects.select_related('team').filter(user=user, team__slug__in=tag_names).first()
if membership:
return Item.Visibility.TEAM, membership.team
visibility = Item.Visibility.PRIVATE if item.visibility == Item.Visibility.TEAM else item.visibility
return visibility, None
def item_scope_fits_collections(item, visibility, team):
sections = item.collection_sections.select_related('collection') if item.pk else CollectionSection.objects.none()
return not any(section.collection.visibility != visibility or section.collection.team_id != (team.id if team else None) for section in sections)
def apply_team_from_tags(item, user, force_private=False):
if force_private:
item.team = None
@@ -514,6 +531,223 @@ def team_accept_invite(request, token):
return redirect('team_detail', pk=invite.team.pk)
def visible_collections(user):
qs = Collection.objects.select_related('owner', 'team').prefetch_related('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)
def user_can_edit_collection(user, collection):
return user.is_authenticated and (collection.owner_id == user.id or (collection.team_id and TeamMembership.objects.filter(team=collection.team, user=user).exists()))
def user_can_delete_collection(user, collection):
return user.is_authenticated and (collection.owner_id == user.id or (collection.team_id and TeamMembership.objects.filter(team=collection.team, user=user, role__in=[TeamMembership.Role.OWNER, TeamMembership.Role.ADMIN]).exists()))
def collection_item_is_compatible(collection, item):
if collection.visibility == Item.Visibility.TEAM:
return item.visibility == Item.Visibility.TEAM and item.team_id == collection.team_id
return item.visibility == collection.visibility and item.team_id is None
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
return form.cleaned_data['visibility'], None
def item_can_change_for_collection(user, item, collection):
if not user_can_edit_item(user, item):
return False
for section in item.collection_sections.select_related('collection').exclude(collection=collection):
other = section.collection
if other.visibility != collection.visibility or other.team_id != collection.team_id:
return False
return True
def remove_team_tags(content):
slugs = Team.objects.values_list('slug', flat=True)
for slug in slugs:
content = re.sub(rf'(?<!\w)#{re.escape(slug)}\b', '', content, flags=re.I)
return re.sub(r'[ \t]{2,}', ' ', content).strip()
def adapt_item_for_collection(item, collection):
item.content = remove_team_tags(item.content)
if collection.team:
item.content = f'{item.content} #{collection.team.slug}'.strip()
item.visibility = collection.visibility
item.team = collection.team
item.save(update_fields=['content', 'visibility', 'team', 'updated_at'])
item.sync_metadata()
item.team = collection.team
item.visibility = collection.visibility
item.save(update_fields=['team', 'visibility', 'updated_at'])
return item
def copy_item_for_collection(item, collection, owner):
copied = Item.objects.create(
owner=owner, team=collection.team, kind=Item.Kind.NOTE, content=item.content,
comment=item.comment, visibility=collection.visibility, url=item.url,
)
for attachment in item.attachments.all():
Attachment.objects.create(item=copied, file=attachment.file.name)
return adapt_item_for_collection(copied, collection)
def add_collection_section(collection, item):
last_position = collection.sections.order_by('-position').values_list('position', flat=True).first()
return CollectionSection.objects.get_or_create(
collection=collection, item=item,
defaults={'position': (last_position + 1) if last_position is not None else 0},
)
def collection_detail_context(request, collection, pending_item=None):
can_edit = user_can_edit_collection(request.user, collection)
q = request.GET.get('q', '').strip()
candidates = Item.objects.none()
if can_edit:
candidates = visible_items(request.user).filter(kind=Item.Kind.NOTE).exclude(collection_sections__collection=collection)
if q:
candidates = candidates.filter(Q(content__icontains=q) | Q(tags__name__icontains=q)).distinct()
candidates = candidates[:30]
return {
'collection': collection,
'sections': collection.sections.select_related('item').prefetch_related('item__tags', 'item__attachments'),
'can_edit': can_edit,
'can_delete': user_can_delete_collection(request.user, collection),
'candidates': candidates,
'q': q,
'pending_item': pending_item,
'pending_can_convert': pending_item and item_can_change_for_collection(request.user, pending_item, collection),
}
@login_required
def collection_list(request):
collections = visible_collections(request.user)
return render(request, 'core/collection_list.html', {'collections': collections})
@login_required
def collection_create(request):
if request.method == 'POST':
form = CollectionForm(request.POST)
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.'))
else:
collection = form.save(commit=False)
collection.owner = request.user
collection.visibility = visibility
collection.team = team
collection.save()
form.save_m2m()
messages.success(request, _('Collection created.'))
return redirect(collection)
else:
form = CollectionForm()
return render(request, 'core/collection_form.html', {'form': form, 'heading': _('Create collection')})
@login_required
def collection_edit(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 request.method == 'POST':
form = CollectionForm(request.POST, instance=collection)
if form.is_valid():
visibility, team = collection_target_from_form(form, request.user)
incompatible = collection.sections.exclude(item__visibility=visibility)
if team:
incompatible = incompatible | collection.sections.exclude(item__team=team)
else:
incompatible = incompatible | collection.sections.exclude(item__team__isnull=True)
if incompatible.exists():
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.'))
else:
collection = form.save(commit=False)
collection.visibility = visibility
collection.team = team
collection.save()
form.save_m2m()
messages.success(request, _('Collection saved.'))
return redirect(collection)
else:
form = CollectionForm(instance=collection)
return render(request, 'core/collection_form.html', {'form': form, 'collection': collection, 'heading': _('Edit collection')})
def collection_detail(request, pk):
collection = get_object_or_404(visible_collections(request.user), pk=pk)
if request.method == 'POST':
if not user_can_edit_collection(request.user, collection):
return JsonResponse({'error': 'forbidden'}, status=403)
action = request.POST.get('action')
if action in {'add', 'confirm_add'}:
item = get_object_or_404(visible_items(request.user), pk=request.POST.get('item_id'), kind=Item.Kind.NOTE)
if collection_item_is_compatible(collection, item):
add_collection_section(collection, item)
return redirect(collection)
if action == 'add':
return render(request, 'core/collection_detail.html', collection_detail_context(request, collection, item))
mode = request.POST.get('mode')
if mode == 'convert':
if not item_can_change_for_collection(request.user, item, collection):
messages.error(request, _('This note is used by an incompatible collection or cannot be edited. Create a copy instead.'))
return redirect(collection)
item = adapt_item_for_collection(item, collection)
elif mode == 'copy':
item = copy_item_for_collection(item, collection, request.user)
else:
return redirect(collection)
add_collection_section(collection, item)
return redirect(collection)
section = get_object_or_404(CollectionSection, pk=request.POST.get('section_id'), collection=collection)
if action == 'remove':
section.delete()
for index, remaining in enumerate(collection.sections.order_by('position', 'id')):
if remaining.position != index:
CollectionSection.objects.filter(pk=remaining.pk).update(position=index)
elif action == 'title':
section.title = request.POST.get('title', '').strip()[:200]
section.save(update_fields=['title'])
elif action in {'up', 'down'}:
direction = -1 if action == 'up' else 1
other = collection.sections.filter(position=section.position + direction).first()
if other:
with transaction.atomic():
temporary = collection.sections.order_by('-position').values_list('position', flat=True).first() + 1000
CollectionSection.objects.filter(pk=section.pk).update(position=temporary)
CollectionSection.objects.filter(pk=other.pk).update(position=section.position)
CollectionSection.objects.filter(pk=section.pk).update(position=other.position)
return redirect(collection)
return render(request, 'core/collection_detail.html', collection_detail_context(request, collection))
@login_required
@require_POST
def collection_delete(request, pk):
collection = get_object_or_404(visible_collections(request.user), pk=pk)
if not user_can_delete_collection(request.user, collection):
return JsonResponse({'error': 'forbidden'}, status=403)
collection.delete()
messages.success(request, _('Collection deleted.'))
return redirect('collection_list')
@login_required
def item_detail(request, pk):
item = get_object_or_404(visible_items(request.user), pk=pk)
@@ -552,6 +786,11 @@ def edit_item(request, pk):
item.content, command_due_at, has_due_command = parse_due_command(item.content)
if item.kind == Item.Kind.TODO:
item.due_at = command_due_at if has_due_command else parse_due_at_from_post(request.POST)
target_visibility, target_team = desired_item_scope(item, request.user, force_private=item.visibility == Item.Visibility.PRIVATE)
if not item_scope_fits_collections(item, target_visibility, target_team):
form.add_error('visibility', _('This visibility is incompatible with a collection that uses the note.'))
return render(request, 'core/_edit_form.html', {'item': item, 'form': form, 'tags': Tag.objects.all()}, status=409)
item.visibility, item.team = target_visibility, target_team
item.save()
form.save_m2m()
attach_files_and_replace_tokens(item, request.FILES.getlist('files'))
@@ -576,6 +815,11 @@ def item_editor(request, pk):
item.content, command_due_at, has_due_command = parse_due_command(item.content)
if item.kind == Item.Kind.TODO:
item.due_at = command_due_at if has_due_command else parse_due_at_from_post(request.POST)
target_visibility, target_team = desired_item_scope(item, request.user, force_private=item.visibility == Item.Visibility.PRIVATE)
if not item_scope_fits_collections(item, target_visibility, target_team):
form.add_error('visibility', _('This visibility is incompatible with a collection that uses the note.'))
return render(request, 'core/item_editor.html', {'item': item, 'form': form, 'tags': Tag.objects.all(), 'next_url': next_url}, status=409)
item.visibility, item.team = target_visibility, target_team
item.save()
form.save_m2m()
attach_files_and_replace_tokens(item, request.FILES.getlist('files'))
@@ -591,7 +835,11 @@ def delete_item(request, pk):
item = get_object_or_404(visible_items(request.user), pk=pk)
if not user_can_delete_item(request.user, item):
return JsonResponse({'error': 'forbidden'}, status=403)
item.delete()
try:
item.delete()
except ProtectedError:
collections = list(item.collection_sections.select_related('collection').values_list('collection__title', flat=True))
return JsonResponse({'error': _('This note is used in collections and cannot be deleted.'), 'collections': collections}, status=409)
return JsonResponse({'ok': True})
@@ -721,7 +969,11 @@ def api_item_detail(request, pk):
if request.method == 'DELETE':
if not user_can_delete_item(user, item):
return JsonResponse({'error': 'forbidden'}, status=403)
item.delete()
try:
item.delete()
except ProtectedError:
collections = list(item.collection_sections.select_related('collection').values_list('collection__title', flat=True))
return JsonResponse({'error': _('This note is used in collections and cannot be deleted.'), 'collections': collections}, status=409)
return JsonResponse({'ok': True})
if not user_can_edit_item(user, item):
return JsonResponse({'error': 'forbidden'}, status=403)
@@ -735,6 +987,10 @@ def api_item_detail(request, pk):
item.due_at = command_due_at
if 'is_done' in data and item.kind == Item.Kind.TODO:
item.completed_at = timezone.now() if item.is_done else None
target_visibility, target_team = desired_item_scope(item, user, force_private=item.visibility == Item.Visibility.PRIVATE)
if not item_scope_fits_collections(item, target_visibility, target_team):
return JsonResponse({'error': _('This visibility is incompatible with a collection that uses the note.')}, status=409)
item.visibility, item.team = target_visibility, target_team
item.save()
item.sync_metadata()
apply_team_from_tags(item, user, force_private=item.visibility == Item.Visibility.PRIVATE)