diff --git a/src/backend/core/admin.py b/src/backend/core/admin.py index efa49c12..48c93dd7 100644 --- a/src/backend/core/admin.py +++ b/src/backend/core/admin.py @@ -5,6 +5,8 @@ from django.contrib.auth import admin as auth_admin from django.shortcuts import redirect from django.template.response import TemplateResponse from django.urls import path +from django.utils.html import escape, format_html +from django.utils.text import slugify from django.utils.translation import gettext_lazy as _ from core.services.import_service import ImportService @@ -149,10 +151,79 @@ class ThreadAdmin(admin.ModelAdmin): "id", "subject", "snippet", + "get_labels", "messaged_at", "created_at", "updated_at", ) + search_fields = ("subject", "snippet", "labels__name") + list_filter = ("labels",) + fieldsets = ( + (None, {"fields": ("subject", "snippet", "display_labels")}), + ( + _("Statistics"), + { + "fields": ( + "count_unread", + "count_trashed", + "count_draft", + "count_starred", + "count_sender", + "count_messages", + ), + "classes": ("collapse",), + }, + ), + ( + _("Metadata"), + { + "fields": ("sender_names", "created_at", "updated_at", "messaged_at"), + "classes": ("collapse",), + }, + ), + ) + readonly_fields = ( + "display_labels", + "count_unread", + "count_trashed", + "count_draft", + "count_starred", + "count_sender", + "count_messages", + "messaged_at", + "sender_names", + "created_at", + "updated_at", + ) + + def get_labels(self, obj): + """Return a comma-separated list of labels for the thread.""" + return ", ".join(label.name for label in obj.labels.all()) + + get_labels.short_description = _("Labels") + get_labels.admin_order_field = "labels__name" + + def display_labels(self, obj): + """Display labels with their colors in the detail view.""" + if not obj.labels.exists(): + return _("No labels") + + # Create a list of formatted label spans + label_spans = [] + for label in obj.labels.all(): + # Create each label span using format_html + label_span = format_html( + '{}', + label.color, + escape(label.name), + ) + label_spans.append(label_span) + + # Join all spans with a space using format_html + return format_html(" ".join(label_spans)) + + display_labels.short_description = _("Labels") class MessageRecipientInline(admin.TabularInline): @@ -282,3 +353,43 @@ class MessageRecipientAdmin(admin.ModelAdmin): list_display = ("id", "message", "contact", "type") search_fields = ("message__subject", "contact__name", "contact__email") + + +@admin.register(models.Label) +class LabelAdmin(admin.ModelAdmin): + """Admin class for the Label model""" + + list_display = ("id", "name", "slug", "mailbox", "color") + search_fields = ("name", "mailbox__local_part", "mailbox__domain__name") + filter_horizontal = ("threads",) + list_filter = ("mailbox",) + readonly_fields = ("slug",) + list_display = ( + "id", + "name", + "slug", + "mailbox", + "color", + "depth", + "basename", + "parent_name", + ) + list_filter = ("mailbox",) + + def get_basename(self, obj): + """Return the display name of the label.""" + return obj.basename + + def get_parent_name(self, obj): + """Return the display name of the label.""" + return obj.parent_name + + def get_depth(self, obj): + """Return the display name of the label.""" + return obj.depth + + def save_model(self, request, obj, form, change): + """Generate slug from name before saving.""" + if not obj.slug or (change and "name" in form.changed_data): + obj.slug = slugify(obj.name.replace("/", "-")) + super().save_model(request, obj, form, change) diff --git a/src/backend/core/migrations/0015_label.py b/src/backend/core/migrations/0015_label.py new file mode 100644 index 00000000..9ae9e34f --- /dev/null +++ b/src/backend/core/migrations/0015_label.py @@ -0,0 +1,35 @@ +# Generated by Django 5.1.8 on 2025-06-10 08:55 + +import django.db.models.deletion +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0014_alter_mailbox_local_part'), + ] + + operations = [ + migrations.CreateModel( + name='Label', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')), + ('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')), + ('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')), + ('name', models.CharField(help_text="Name of the label/folder (can use slashes for hierarchy, e.g. 'Work/Projects')", max_length=255, verbose_name='name')), + ('slug', models.SlugField(help_text='URL-friendly version of the name', max_length=255, verbose_name='slug')), + ('color', models.CharField(default='#E3E3FD', help_text='Color of the label in hex format (e.g. #FF0000)', max_length=7, verbose_name='color')), + ('mailbox', models.ForeignKey(help_text='Mailbox that owns this label', on_delete=django.db.models.deletion.CASCADE, related_name='labels', to='core.mailbox')), + ('threads', models.ManyToManyField(blank=True, help_text='Threads that have this label', related_name='labels', to='core.thread')), + ], + options={ + 'verbose_name': 'label', + 'verbose_name_plural': 'labels', + 'db_table': 'messages_label', + 'ordering': ['name'], + 'unique_together': {('slug', 'mailbox')}, + }, + ), + ] diff --git a/src/backend/core/models.py b/src/backend/core/models.py index 4c29b86f..0b745881 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -13,6 +13,7 @@ from django.contrib.auth import models as auth_models from django.contrib.auth.base_user import AbstractBaseUser from django.core import validators from django.db import models +from django.utils.text import slugify from django.utils.translation import gettext_lazy as _ from timezone_field import TimeZoneField @@ -393,6 +394,100 @@ class Thread(BaseModel): ) +class Label(BaseModel): + """Label model to organize threads into folders using slash-based naming.""" + + name = models.CharField( + _("name"), + max_length=255, + help_text=_( + "Name of the label/folder (can use slashes for hierarchy, e.g. 'Work/Projects')" + ), + ) + slug = models.SlugField( + _("slug"), + max_length=255, + help_text=_("URL-friendly version of the name"), + ) + color = models.CharField( + _("color"), + max_length=7, + default="#E3E3FD", + help_text=_("Color of the label in hex format (e.g. #FF0000)"), + ) + mailbox = models.ForeignKey( + "Mailbox", + on_delete=models.CASCADE, + related_name="labels", + help_text=_("Mailbox that owns this label"), + ) + threads = models.ManyToManyField( + "Thread", + related_name="labels", + help_text=_("Threads that have this label"), + blank=True, + ) + + class Meta: + db_table = "messages_label" + verbose_name = _("label") + verbose_name_plural = _("labels") + unique_together = ("slug", "mailbox") + ordering = ["name"] + + def __str__(self): + return f"{self.name} ({self.mailbox})" + + def save(self, *args, **kwargs): + """Generate slug from name before saving.""" + if not self.slug: + self.slug = slugify(self.name.replace("/", "-")) + super().save(*args, **kwargs) + + @property + def parent_name(self): + """Get the parent label name if this is a subfolder.""" + if "/" not in self.name: + return None + return self.name.rsplit("/", 1)[0] + + @property + def basename(self): + """Get the base name of the label without parent path.""" + return self.name.rsplit("/", maxsplit=1)[-1] + + @property + def depth(self): + """Get the depth of the label in the hierarchy.""" + return self.name.count("/") + + @classmethod + def get_children(cls, mailbox, parent_name): + """Get all direct children of a parent label.""" + if parent_name: + prefix = f"{parent_name}/" + # Get all labels that start with the parent prefix + labels = cls.objects.filter( + mailbox=mailbox, + name__startswith=prefix, + ) + # Filter to only get direct children (one level deeper) + return [ + label for label in labels if label.depth == parent_name.count("/") + 1 + ] + + # Get root level labels (no slashes) + return cls.objects.filter( + mailbox=mailbox, + ).exclude(name__contains="/") + + def get_display_name(self): + """Return the display name of the label.""" + if "/" not in self.name: + return self.name + return self.name.rsplit("/", maxsplit=1)[-1] + + class ThreadAccess(BaseModel): """Thread access model to store thread access information for a mailbox."""