Based on the git diff provided, here's a concise and descriptive commit message:

feat: add passkey authentication and enhance user preferences

- Add passkey login security event type with fingerprint icon
- Include request and site context in email confirmation for backend
- Add user_id exact match filter to prevent incorrect user lookups
- Enable PATCH method for updating user preferences via API
- Add moderation_preferences support to user settings
- Optimize ticket queries with select_related and prefetch_related

This commit introduces passkey authentication tracking, improves user
profile filtering accuracy, and extends the preferences API to support
updates. Query optimizations reduce database hits for ticket listings.
This commit is contained in:
pacnpal
2026-01-12 19:13:05 -05:00
parent 2b66814d82
commit d631f3183c
56 changed files with 5860 additions and 264 deletions

View File

@@ -1,14 +1,27 @@
from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import models
from django.utils import timezone
from apps.core.choices.fields import RichChoiceField
from apps.core.history import TrackedModel
from apps.core.state_machine import RichFSMField, StateMachineMixin
# Import choices to ensure registration on app load
from . import choices # noqa: F401
class Ticket(TrackedModel):
class Ticket(StateMachineMixin, TrackedModel):
"""
Support ticket model with FSM-managed status transitions.
Status workflow:
open -> in_progress -> closed
-> open (reopen)
"""
state_field_name = "status"
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
@@ -18,6 +31,13 @@ class Ticket(TrackedModel):
help_text="User who submitted the ticket (optional)",
)
# Submitter info (for anonymous/guest tickets)
name = models.CharField(
max_length=255,
blank=True,
help_text="Name of the submitter (for anonymous tickets)",
)
category = RichChoiceField(
choice_group="ticket_categories",
domain="support",
@@ -30,7 +50,8 @@ class Ticket(TrackedModel):
message = models.TextField()
email = models.EmailField(help_text="Contact email", blank=True)
status = RichChoiceField(
# FSM-managed status field
status = RichFSMField(
choice_group="ticket_statuses",
domain="support",
max_length=20,
@@ -38,20 +59,250 @@ class Ticket(TrackedModel):
db_index=True,
)
# Human-readable ticket number (e.g., TKT-00001)
ticket_number = models.CharField(
max_length=20,
unique=True,
blank=True,
help_text="Human-readable ticket number",
)
# Admin management fields
admin_notes = models.TextField(
blank=True,
help_text="Internal notes for administrators",
)
assigned_to = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="assigned_tickets",
help_text="Staff member assigned to this ticket",
)
# Resolution tracking
resolved_at = models.DateTimeField(
null=True,
blank=True,
help_text="When the ticket was resolved",
)
resolved_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="resolved_tickets",
help_text="Staff member who resolved this ticket",
)
# Archive functionality
archived_at = models.DateTimeField(
null=True,
blank=True,
help_text="When the ticket was archived",
)
archived_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="archived_tickets",
help_text="Staff member who archived this ticket",
)
class Meta(TrackedModel.Meta):
verbose_name = "Ticket"
verbose_name_plural = "Tickets"
ordering = ["-created_at"]
indexes = [
models.Index(fields=["status", "created_at"]),
models.Index(fields=["ticket_number"]),
models.Index(fields=["archived_at"]),
]
def __str__(self):
return f"[{self.get_category_display()}] {self.subject}"
return f"[{self.ticket_number}] {self.subject}"
def save(self, *args, **kwargs):
# If user is set but email is empty, autofill from user
if self.user and not self.email:
self.email = self.user.email
# Generate ticket number if not set
if not self.ticket_number:
self.ticket_number = self._generate_ticket_number()
super().save(*args, **kwargs)
def _generate_ticket_number(self):
"""Generate a unique ticket number like TKT-00001."""
import uuid
# Use last 8 chars of a UUID for uniqueness
suffix = uuid.uuid4().hex[:8].upper()
return f"TKT-{suffix}"
# =========================================================================
# FSM Transition Methods
# =========================================================================
def start_progress(self, user=None) -> None:
"""
Start working on this ticket.
Transition: open -> in_progress
Args:
user: The staff member starting work on the ticket
"""
if self.status != "open":
raise ValidationError(
f"Cannot start progress: current status is {self.status}, expected open"
)
self.status = "in_progress"
if user and user.is_staff:
self.assigned_to = user
self.save()
def close(self, user=None, notes: str = "") -> None:
"""
Close/resolve this ticket.
Transition: in_progress -> closed
Args:
user: The staff member closing the ticket
notes: Optional resolution notes
"""
if self.status not in ("open", "in_progress"):
raise ValidationError(
f"Cannot close: current status is {self.status}, expected open or in_progress"
)
self.status = "closed"
self.resolved_at = timezone.now()
if user:
self.resolved_by = user
if notes:
self.admin_notes = f"{self.admin_notes}\n\n[CLOSED] {notes}".strip()
self.save()
def reopen(self, user=None, reason: str = "") -> None:
"""
Reopen a closed ticket.
Transition: closed -> open
Args:
user: The staff member reopening the ticket
reason: Reason for reopening
"""
if self.status != "closed":
raise ValidationError(
f"Cannot reopen: current status is {self.status}, expected closed"
)
self.status = "open"
self.resolved_at = None
self.resolved_by = None
if reason:
self.admin_notes = f"{self.admin_notes}\n\n[REOPENED] {reason}".strip()
self.save()
def archive(self, user=None, reason: str = "") -> None:
"""
Archive this ticket.
Can be called from any status.
Args:
user: The staff member archiving the ticket
reason: Reason for archiving
"""
if self.archived_at:
raise ValidationError("Ticket is already archived")
self.archived_at = timezone.now()
if user:
self.archived_by = user
if reason:
self.admin_notes = f"{self.admin_notes}\n\n[ARCHIVED] {reason}".strip()
self.save()
def unarchive(self, user=None) -> None:
"""
Restore an archived ticket.
Args:
user: The staff member unarchiving the ticket
"""
if not self.archived_at:
raise ValidationError("Ticket is not archived")
self.archived_at = None
self.archived_by = None
self.admin_notes = f"{self.admin_notes}\n\n[UNARCHIVED] by {user.username if user else 'system'}".strip()
self.save()
# =========================================================================
# Computed Properties
# =========================================================================
@property
def thread_id(self):
"""Return the ID for email thread association."""
return str(self.id)
@property
def response_count(self):
"""Return number of email thread responses."""
return self.email_threads.count()
@property
def last_admin_response_at(self):
"""Return timestamp of last admin response."""
last_outbound = self.email_threads.filter(direction="outbound").order_by("-created_at").first()
return last_outbound.created_at if last_outbound else None
class EmailThread(TrackedModel):
"""Email thread entries for ticket conversations."""
ticket = models.ForeignKey(
Ticket,
on_delete=models.CASCADE,
related_name="email_threads",
help_text="Associated support ticket",
)
message_id = models.CharField(
max_length=255,
blank=True,
help_text="Email message ID for threading",
)
from_email = models.EmailField(help_text="Sender email address")
to_email = models.EmailField(help_text="Recipient email address")
subject = models.CharField(max_length=255)
body_text = models.TextField(help_text="Plain text email body")
direction = RichChoiceField(
choice_group="email_directions",
domain="support",
max_length=10,
help_text="Whether email is inbound or outbound",
)
sent_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="sent_email_threads",
help_text="Staff member who sent this email (for outbound)",
)
class Meta(TrackedModel.Meta):
verbose_name = "Email Thread"
verbose_name_plural = "Email Threads"
ordering = ["created_at"]
def __str__(self):
return f"[{self.direction}] {self.subject}"
class Report(TrackedModel):
"""
@@ -61,20 +312,6 @@ class Report(TrackedModel):
(parks, rides, reviews, etc.) for moderator review.
"""
class ReportType(models.TextChoices):
INACCURATE = "inaccurate", "Inaccurate Information"
INAPPROPRIATE = "inappropriate", "Inappropriate Content"
SPAM = "spam", "Spam"
COPYRIGHT = "copyright", "Copyright Violation"
DUPLICATE = "duplicate", "Duplicate Content"
OTHER = "other", "Other"
class Status(models.TextChoices):
PENDING = "pending", "Pending"
INVESTIGATING = "investigating", "Investigating"
RESOLVED = "resolved", "Resolved"
DISMISSED = "dismissed", "Dismissed"
# Reporter (optional for anonymous reports)
reporter = models.ForeignKey(
settings.AUTH_USER_MODEL,
@@ -99,20 +336,22 @@ class Report(TrackedModel):
# It's a convenience for accessing the related object
# content_object = GenericForeignKey("content_type", "object_id")
# Report details
report_type = models.CharField(
# Report details - now using RichChoiceField
report_type = RichChoiceField(
choice_group="report_types",
domain="support",
max_length=20,
choices=ReportType.choices,
db_index=True,
help_text="Type of issue being reported",
)
reason = models.TextField(
help_text="Detailed description of the issue",
)
status = models.CharField(
status = RichChoiceField(
choice_group="report_statuses",
domain="support",
max_length=20,
choices=Status.choices,
default=Status.PENDING,
default="pending",
db_index=True,
help_text="Current status of the report",
)
@@ -151,5 +390,6 @@ class Report(TrackedModel):
@property
def is_resolved(self) -> bool:
return self.status in (self.Status.RESOLVED, self.Status.DISMISSED)
return self.status in ("resolved", "dismissed")