(backend) add Label model to organize threads

Create new model Label.
Add django admin interfaces.
This commit is contained in:
Sabrina Demagny
2025-06-10 14:50:09 +02:00
parent c6b4e4334b
commit 78e3740686
3 changed files with 241 additions and 0 deletions
+111
View File
@@ -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(
'<span style="display: inline-block; padding: 2px 8px; margin: 2px; '
'border-radius: 3px; background-color: {}; color: white;">{}</span>',
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)
+35
View File
@@ -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')},
},
),
]
+95
View File
@@ -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."""