mirror of
https://github.com/suitenumerique/messages.git
synced 2026-09-07 18:37:48 +02:00
✨(backend) add Label model to organize threads
Create new model Label. Add django admin interfaces.
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user