diff --git a/core/admin.py b/core/admin.py index d8867b2..82fed48 100644 --- a/core/admin.py +++ b/core/admin.py @@ -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): diff --git a/core/forms.py b/core/forms.py index abb4b1e..196ea61 100644 --- a/core/forms.py +++ b/core/forms.py @@ -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 diff --git a/core/migrations/0017_collection_collectionsection.py b/core/migrations/0017_collection_collectionsection.py new file mode 100644 index 0000000..578a978 --- /dev/null +++ b/core/migrations/0017_collection_collectionsection.py @@ -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')], + }, + ), + ] diff --git a/core/models.py b/core/models.py index b7c90ec..27ee95b 100644 --- a/core/models.py +++ b/core/models.py @@ -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')]) diff --git a/core/templates/core/_edit_form.html b/core/templates/core/_edit_form.html index 4a0c152..e8fe1c1 100644 --- a/core/templates/core/_edit_form.html +++ b/core/templates/core/_edit_form.html @@ -2,6 +2,7 @@