Add desktop note collections
This commit is contained in:
+13
-1
@@ -1,5 +1,17 @@
|
|||||||
from django.contrib import admin
|
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):
|
class AttachmentInline(admin.TabularInline):
|
||||||
|
|||||||
+19
-1
@@ -2,7 +2,7 @@ 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.utils.translation import gettext_lazy as _
|
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):
|
class MultipleFileInput(forms.ClearableFileInput):
|
||||||
@@ -133,6 +133,24 @@ class KanbanForm(forms.ModelForm):
|
|||||||
return super().save(commit=commit)
|
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 ApiKeyForm(forms.ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = ApiKey
|
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
@@ -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).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),
|
name__in=Team.objects.values_list('slug', flat=True),
|
||||||
).delete()
|
).delete()
|
||||||
|
|
||||||
@@ -195,6 +195,44 @@ class Kanban(models.Model):
|
|||||||
return self.name
|
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):
|
class UserPreference(models.Model):
|
||||||
user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='preferences')
|
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')])
|
language = models.CharField(max_length=10, blank=True, default='', choices=[('', _('Browser default')), ('de', 'Deutsch'), ('en', 'English')])
|
||||||
|
|||||||
@@ -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">
|
<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 %}
|
{% csrf_token %}
|
||||||
<div class="card-body vstack gap-2">
|
<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="small text-muted">{% trans "Type:" %} {{ item.get_kind_display }}</div>
|
||||||
<div class="mobile-tag-picker">
|
<div class="mobile-tag-picker">
|
||||||
<div class="small text-muted mb-1">{% trans "Add tags" %}</div>
|
<div class="small text-muted mb-1">{% trans "Add tags" %}</div>
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
<div class="d-none d-md-flex align-items-center gap-2">
|
<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>
|
<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 %}
|
{% 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>
|
||||||
<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>
|
<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 %}
|
{% 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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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">
|
<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 %}
|
{% csrf_token %}
|
||||||
<div class="card-body vstack gap-3">
|
<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">
|
<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>
|
<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>
|
<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
@@ -2,7 +2,7 @@ from django.contrib.auth import get_user_model
|
|||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django.urls import reverse
|
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 .templatetags.markdown_extras import markdown
|
||||||
from .views import parse_due_command, parse_quick_content
|
from .views import parse_due_command, parse_quick_content
|
||||||
|
|
||||||
@@ -147,6 +147,87 @@ class NavigationAndLayoutTests(TestCase):
|
|||||||
self.assertLess(toolbar_position, file_position)
|
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):
|
class CopyButtonTests(TestCase):
|
||||||
def test_item_has_copy_button_with_original_text(self):
|
def test_item_has_copy_button_with_original_text(self):
|
||||||
user = get_user_model().objects.create_user(username='tester', password='secret')
|
user = get_user_model().objects.create_user(username='tester', password='secret')
|
||||||
|
|||||||
@@ -19,6 +19,11 @@ urlpatterns = [
|
|||||||
path('teams/', views.team_list, name='team_list'),
|
path('teams/', views.team_list, name='team_list'),
|
||||||
path('teams/<int:pk>/', views.team_detail, name='team_detail'),
|
path('teams/<int:pk>/', views.team_detail, name='team_detail'),
|
||||||
path('teams/invite/<str:token>/', views.team_accept_invite, name='team_accept_invite'),
|
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>/', views.item_detail, name='item_detail'),
|
||||||
path('items/<int:pk>/card/', views.item_card, name='item_card'),
|
path('items/<int:pk>/card/', views.item_card, name='item_card'),
|
||||||
path('items/<int:pk>/edit/', views.edit_item, name='edit_item'),
|
path('items/<int:pk>/edit/', views.edit_item, name='edit_item'),
|
||||||
|
|||||||
+261
-5
@@ -9,7 +9,8 @@ from django.conf import settings
|
|||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.contrib.auth import login
|
from django.contrib.auth import login
|
||||||
from django.contrib.auth.decorators import login_required
|
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.http import HttpResponse, JsonResponse
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils import timezone
|
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.shortcuts import get_object_or_404, redirect, render
|
||||||
from django.views.decorators.csrf import csrf_exempt
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
from django.views.decorators.http import require_http_methods, require_POST
|
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 .forms import ApiKeyForm, CollectionForm, EditItemForm, InviteRegistrationForm, KanbanForm, QuickItemForm, TeamForm, TeamInviteForm, UserInviteForm, UserPreferenceForm, UserSettingsForm
|
||||||
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
|
||||||
|
|
||||||
TYPE_COMMAND_RE = re.compile(r'^/(todo|note|link|journal)\b', re.I)
|
TYPE_COMMAND_RE = re.compile(r'^/(todo|note|link|journal)\b', re.I)
|
||||||
VISIBILITY_COMMAND_RE = re.compile(r'/(public|private)\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())
|
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):
|
def apply_team_from_tags(item, user, force_private=False):
|
||||||
if force_private:
|
if force_private:
|
||||||
item.team = None
|
item.team = None
|
||||||
@@ -514,6 +531,223 @@ def team_accept_invite(request, token):
|
|||||||
return redirect('team_detail', pk=invite.team.pk)
|
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
|
@login_required
|
||||||
def item_detail(request, pk):
|
def item_detail(request, pk):
|
||||||
item = get_object_or_404(visible_items(request.user), pk=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)
|
item.content, command_due_at, has_due_command = parse_due_command(item.content)
|
||||||
if item.kind == Item.Kind.TODO:
|
if item.kind == Item.Kind.TODO:
|
||||||
item.due_at = command_due_at if has_due_command else parse_due_at_from_post(request.POST)
|
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()
|
item.save()
|
||||||
form.save_m2m()
|
form.save_m2m()
|
||||||
attach_files_and_replace_tokens(item, request.FILES.getlist('files'))
|
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)
|
item.content, command_due_at, has_due_command = parse_due_command(item.content)
|
||||||
if item.kind == Item.Kind.TODO:
|
if item.kind == Item.Kind.TODO:
|
||||||
item.due_at = command_due_at if has_due_command else parse_due_at_from_post(request.POST)
|
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()
|
item.save()
|
||||||
form.save_m2m()
|
form.save_m2m()
|
||||||
attach_files_and_replace_tokens(item, request.FILES.getlist('files'))
|
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)
|
item = get_object_or_404(visible_items(request.user), pk=pk)
|
||||||
if not user_can_delete_item(request.user, item):
|
if not user_can_delete_item(request.user, item):
|
||||||
return JsonResponse({'error': 'forbidden'}, status=403)
|
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})
|
return JsonResponse({'ok': True})
|
||||||
|
|
||||||
|
|
||||||
@@ -721,7 +969,11 @@ def api_item_detail(request, pk):
|
|||||||
if request.method == 'DELETE':
|
if request.method == 'DELETE':
|
||||||
if not user_can_delete_item(user, item):
|
if not user_can_delete_item(user, item):
|
||||||
return JsonResponse({'error': 'forbidden'}, status=403)
|
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})
|
return JsonResponse({'ok': True})
|
||||||
if not user_can_edit_item(user, item):
|
if not user_can_edit_item(user, item):
|
||||||
return JsonResponse({'error': 'forbidden'}, status=403)
|
return JsonResponse({'error': 'forbidden'}, status=403)
|
||||||
@@ -735,6 +987,10 @@ def api_item_detail(request, pk):
|
|||||||
item.due_at = command_due_at
|
item.due_at = command_due_at
|
||||||
if 'is_done' in data and item.kind == Item.Kind.TODO:
|
if 'is_done' in data and item.kind == Item.Kind.TODO:
|
||||||
item.completed_at = timezone.now() if item.is_done else None
|
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.save()
|
||||||
item.sync_metadata()
|
item.sync_metadata()
|
||||||
apply_team_from_tags(item, user, force_private=item.visibility == Item.Visibility.PRIVATE)
|
apply_team_from_tags(item, user, force_private=item.visibility == Item.Visibility.PRIVATE)
|
||||||
|
|||||||
@@ -644,6 +644,162 @@ msgstr "Team %(team)s erstellt. Nutze #%(slug)s für Team-Einträge."
|
|||||||
msgid "You are now a member of team %(team)s."
|
msgid "You are now a member of team %(team)s."
|
||||||
msgstr "Du bist jetzt Mitglied im Team %(team)s."
|
msgstr "Du bist jetzt Mitglied im Team %(team)s."
|
||||||
|
|
||||||
|
#: core/forms.py core/templates/core/collection_form.html
|
||||||
|
msgid "Title"
|
||||||
|
msgstr "Titel"
|
||||||
|
|
||||||
|
#: core/forms.py core/templates/core/collection_form.html
|
||||||
|
msgid "Description"
|
||||||
|
msgstr "Beschreibung"
|
||||||
|
|
||||||
|
#: core/templates/core/base.html core/templates/core/collection_detail.html core/templates/core/collection_list.html
|
||||||
|
msgid "Collections"
|
||||||
|
msgstr "Sammlungen"
|
||||||
|
|
||||||
|
#: core/templates/core/collection_detail.html core/templates/core/collection_form.html core/templates/core/collection_list.html
|
||||||
|
msgid "Collections are currently available on desktop only."
|
||||||
|
msgstr "Sammlungen sind derzeit nur auf dem Desktop verfügbar."
|
||||||
|
|
||||||
|
#: core/templates/core/collection_detail.html
|
||||||
|
msgid "Edit collection"
|
||||||
|
msgstr "Sammlung bearbeiten"
|
||||||
|
|
||||||
|
#: core/templates/core/collection_detail.html
|
||||||
|
msgid "Delete collection? The notes remain available."
|
||||||
|
msgstr "Sammlung löschen? Die Notizen bleiben erhalten."
|
||||||
|
|
||||||
|
#: core/templates/core/collection_detail.html
|
||||||
|
msgid "Contents"
|
||||||
|
msgstr "Inhalt"
|
||||||
|
|
||||||
|
#: core/templates/core/collection_detail.html
|
||||||
|
msgid "The note has a different visibility"
|
||||||
|
msgstr "Die Notiz hat eine andere Sichtbarkeit"
|
||||||
|
|
||||||
|
#: core/templates/core/collection_detail.html
|
||||||
|
msgid "To add it, change its visibility or create a separate copy for this collection."
|
||||||
|
msgstr "Um sie hinzuzufügen, ändere ihre Sichtbarkeit oder erstelle eine eigene Kopie für diese Sammlung."
|
||||||
|
|
||||||
|
#: core/templates/core/collection_detail.html
|
||||||
|
msgid "Make note public"
|
||||||
|
msgstr "Notiz öffentlich machen"
|
||||||
|
|
||||||
|
#: core/templates/core/collection_detail.html
|
||||||
|
msgid "Share note with team"
|
||||||
|
msgstr "Notiz mit Team teilen"
|
||||||
|
|
||||||
|
#: core/templates/core/collection_detail.html
|
||||||
|
msgid "Make note private"
|
||||||
|
msgstr "Notiz privat machen"
|
||||||
|
|
||||||
|
#: core/templates/core/collection_detail.html
|
||||||
|
msgid "Create compatible copy"
|
||||||
|
msgstr "Passende Kopie erstellen"
|
||||||
|
|
||||||
|
#: core/templates/core/collection_detail.html
|
||||||
|
msgid "The original cannot be changed because it is used elsewhere or you cannot edit it."
|
||||||
|
msgstr "Das Original kann nicht geändert werden, weil es anderswo verwendet wird oder du es nicht bearbeiten darfst."
|
||||||
|
|
||||||
|
#: core/templates/core/collection_detail.html
|
||||||
|
msgid "Optional section title"
|
||||||
|
msgstr "Optionaler Abschnittstitel"
|
||||||
|
|
||||||
|
#: core/templates/core/collection_detail.html
|
||||||
|
msgid "Save title"
|
||||||
|
msgstr "Titel speichern"
|
||||||
|
|
||||||
|
#: core/templates/core/collection_detail.html
|
||||||
|
msgid "Move up"
|
||||||
|
msgstr "Nach oben"
|
||||||
|
|
||||||
|
#: core/templates/core/collection_detail.html
|
||||||
|
msgid "Move down"
|
||||||
|
msgstr "Nach unten"
|
||||||
|
|
||||||
|
#: core/templates/core/collection_detail.html
|
||||||
|
msgid "Remove"
|
||||||
|
msgstr "Entfernen"
|
||||||
|
|
||||||
|
#: core/templates/core/collection_detail.html
|
||||||
|
msgid "This collection has no sections yet."
|
||||||
|
msgstr "Diese Sammlung hat noch keine Abschnitte."
|
||||||
|
|
||||||
|
#: core/templates/core/collection_detail.html
|
||||||
|
msgid "Add existing note"
|
||||||
|
msgstr "Bestehende Notiz hinzufügen"
|
||||||
|
|
||||||
|
#: core/templates/core/collection_detail.html
|
||||||
|
msgid "Search notes"
|
||||||
|
msgstr "Notizen suchen"
|
||||||
|
|
||||||
|
#: core/templates/core/collection_detail.html
|
||||||
|
msgid "Search"
|
||||||
|
msgstr "Suchen"
|
||||||
|
|
||||||
|
#: core/templates/core/collection_detail.html
|
||||||
|
msgid "Add"
|
||||||
|
msgstr "Hinzufügen"
|
||||||
|
|
||||||
|
#: core/templates/core/collection_detail.html
|
||||||
|
msgid "No matching notes."
|
||||||
|
msgstr "Keine passenden Notizen."
|
||||||
|
|
||||||
|
#: core/templates/core/collection_form.html
|
||||||
|
msgid "Adding one of your team tags turns this into a team collection."
|
||||||
|
msgstr "Durch Hinzufügen eines deiner Team-Tags wird daraus eine Team-Sammlung."
|
||||||
|
|
||||||
|
#: core/templates/core/collection_list.html
|
||||||
|
msgid "Combine existing notes into ordered documents."
|
||||||
|
msgstr "Fasse bestehende Notizen zu geordneten Dokumenten zusammen."
|
||||||
|
|
||||||
|
#: core/templates/core/collection_list.html
|
||||||
|
msgid "New collection"
|
||||||
|
msgstr "Neue Sammlung"
|
||||||
|
|
||||||
|
#: core/templates/core/collection_list.html
|
||||||
|
msgid "sections"
|
||||||
|
msgstr "Abschnitte"
|
||||||
|
|
||||||
|
#: core/templates/core/collection_list.html
|
||||||
|
msgid "No collections yet."
|
||||||
|
msgstr "Noch keine Sammlungen."
|
||||||
|
|
||||||
|
#: core/views.py
|
||||||
|
msgid "Select a team tag for a team collection."
|
||||||
|
msgstr "Wähle für eine Team-Sammlung ein Team-Tag aus."
|
||||||
|
|
||||||
|
#: core/views.py
|
||||||
|
msgid "Collection created."
|
||||||
|
msgstr "Sammlung erstellt."
|
||||||
|
|
||||||
|
#: core/views.py
|
||||||
|
msgid "Create collection"
|
||||||
|
msgstr "Sammlung erstellen"
|
||||||
|
|
||||||
|
#: core/views.py
|
||||||
|
msgid "The collection visibility cannot change while it contains incompatible notes."
|
||||||
|
msgstr "Die Sichtbarkeit kann nicht geändert werden, solange die Sammlung unpassende Notizen enthält."
|
||||||
|
|
||||||
|
#: core/views.py
|
||||||
|
msgid "Collection saved."
|
||||||
|
msgstr "Sammlung gespeichert."
|
||||||
|
|
||||||
|
#: core/views.py
|
||||||
|
msgid "This note is used by an incompatible collection or cannot be edited. Create a copy instead."
|
||||||
|
msgstr "Diese Notiz wird in einer unpassenden Sammlung verwendet oder kann nicht bearbeitet werden. Erstelle stattdessen eine Kopie."
|
||||||
|
|
||||||
|
#: core/views.py
|
||||||
|
msgid "Collection deleted."
|
||||||
|
msgstr "Sammlung gelöscht."
|
||||||
|
|
||||||
|
#: core/views.py
|
||||||
|
msgid "This note is used in collections and cannot be deleted."
|
||||||
|
msgstr "Diese Notiz wird in Sammlungen verwendet und kann nicht gelöscht werden."
|
||||||
|
|
||||||
|
#: core/views.py
|
||||||
|
msgid "This visibility is incompatible with a collection that uses the note."
|
||||||
|
msgstr "Diese Sichtbarkeit passt nicht zu einer Sammlung, die diese Notiz verwendet."
|
||||||
|
|
||||||
#: core/templates/core/_format_toolbar.html
|
#: core/templates/core/_format_toolbar.html
|
||||||
msgid "Bold"
|
msgid "Bold"
|
||||||
msgstr "Fett"
|
msgstr "Fett"
|
||||||
|
|||||||
Reference in New Issue
Block a user