mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2026-02-05 12:55:17 -05:00
Compare commits
4 Commits
96df23242e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fbfda9a3d8 | ||
|
|
4140a0d8e7 | ||
|
|
d631f3183c | ||
|
|
2b66814d82 |
@@ -32,6 +32,8 @@ class CustomAccountAdapter(DefaultAccountAdapter):
|
||||
"activate_url": activate_url,
|
||||
"current_site": current_site,
|
||||
"key": emailconfirmation.key,
|
||||
"request": request, # Include request for email backend
|
||||
"site": current_site, # Include site for email backend
|
||||
}
|
||||
email_template = "account/email/email_confirmation_signup" if signup else "account/email/email_confirmation"
|
||||
self.send_mail(email_template, emailconfirmation.email_address.email, ctx)
|
||||
|
||||
@@ -586,6 +586,264 @@ notification_priorities = ChoiceGroup(
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# SECURITY EVENT TYPES
|
||||
# =============================================================================
|
||||
|
||||
security_event_types = ChoiceGroup(
|
||||
name="security_event_types",
|
||||
choices=[
|
||||
RichChoice(
|
||||
value="login_success",
|
||||
label="Login Success",
|
||||
description="User successfully logged in to their account",
|
||||
metadata={
|
||||
"color": "green",
|
||||
"icon": "login",
|
||||
"css_class": "text-green-600 bg-green-50",
|
||||
"severity": "info",
|
||||
"category": "authentication",
|
||||
"sort_order": 1,
|
||||
},
|
||||
),
|
||||
RichChoice(
|
||||
value="login_failed",
|
||||
label="Login Failed",
|
||||
description="Failed login attempt to user's account",
|
||||
metadata={
|
||||
"color": "red",
|
||||
"icon": "login",
|
||||
"css_class": "text-red-600 bg-red-50",
|
||||
"severity": "warning",
|
||||
"category": "authentication",
|
||||
"sort_order": 2,
|
||||
},
|
||||
),
|
||||
RichChoice(
|
||||
value="logout",
|
||||
label="Logout",
|
||||
description="User logged out of their account",
|
||||
metadata={
|
||||
"color": "gray",
|
||||
"icon": "logout",
|
||||
"css_class": "text-gray-600 bg-gray-50",
|
||||
"severity": "info",
|
||||
"category": "authentication",
|
||||
"sort_order": 3,
|
||||
},
|
||||
),
|
||||
RichChoice(
|
||||
value="mfa_enrolled",
|
||||
label="MFA Enrolled",
|
||||
description="User enabled two-factor authentication",
|
||||
metadata={
|
||||
"color": "green",
|
||||
"icon": "shield-check",
|
||||
"css_class": "text-green-600 bg-green-50",
|
||||
"severity": "info",
|
||||
"category": "mfa",
|
||||
"sort_order": 4,
|
||||
},
|
||||
),
|
||||
RichChoice(
|
||||
value="mfa_disabled",
|
||||
label="MFA Disabled",
|
||||
description="User disabled two-factor authentication",
|
||||
metadata={
|
||||
"color": "yellow",
|
||||
"icon": "shield-off",
|
||||
"css_class": "text-yellow-600 bg-yellow-50",
|
||||
"severity": "warning",
|
||||
"category": "mfa",
|
||||
"sort_order": 5,
|
||||
},
|
||||
),
|
||||
RichChoice(
|
||||
value="mfa_challenge_success",
|
||||
label="MFA Challenge Success",
|
||||
description="User successfully completed MFA verification",
|
||||
metadata={
|
||||
"color": "green",
|
||||
"icon": "shield-check",
|
||||
"css_class": "text-green-600 bg-green-50",
|
||||
"severity": "info",
|
||||
"category": "mfa",
|
||||
"sort_order": 6,
|
||||
},
|
||||
),
|
||||
RichChoice(
|
||||
value="mfa_challenge_failed",
|
||||
label="MFA Challenge Failed",
|
||||
description="User failed MFA verification attempt",
|
||||
metadata={
|
||||
"color": "red",
|
||||
"icon": "shield-x",
|
||||
"css_class": "text-red-600 bg-red-50",
|
||||
"severity": "warning",
|
||||
"category": "mfa",
|
||||
"sort_order": 7,
|
||||
},
|
||||
),
|
||||
RichChoice(
|
||||
value="passkey_registered",
|
||||
label="Passkey Registered",
|
||||
description="User registered a new passkey/WebAuthn credential",
|
||||
metadata={
|
||||
"color": "green",
|
||||
"icon": "fingerprint",
|
||||
"css_class": "text-green-600 bg-green-50",
|
||||
"severity": "info",
|
||||
"category": "passkey",
|
||||
"sort_order": 8,
|
||||
},
|
||||
),
|
||||
RichChoice(
|
||||
value="passkey_removed",
|
||||
label="Passkey Removed",
|
||||
description="User removed a passkey/WebAuthn credential",
|
||||
metadata={
|
||||
"color": "yellow",
|
||||
"icon": "fingerprint",
|
||||
"css_class": "text-yellow-600 bg-yellow-50",
|
||||
"severity": "warning",
|
||||
"category": "passkey",
|
||||
"sort_order": 9,
|
||||
},
|
||||
),
|
||||
RichChoice(
|
||||
value="passkey_login",
|
||||
label="Passkey Login",
|
||||
description="User logged in using a passkey",
|
||||
metadata={
|
||||
"color": "green",
|
||||
"icon": "fingerprint",
|
||||
"css_class": "text-green-600 bg-green-50",
|
||||
"severity": "info",
|
||||
"category": "passkey",
|
||||
"sort_order": 10,
|
||||
},
|
||||
),
|
||||
RichChoice(
|
||||
value="social_linked",
|
||||
label="Social Account Linked",
|
||||
description="User connected a social login provider",
|
||||
metadata={
|
||||
"color": "blue",
|
||||
"icon": "link",
|
||||
"css_class": "text-blue-600 bg-blue-50",
|
||||
"severity": "info",
|
||||
"category": "social",
|
||||
"sort_order": 11,
|
||||
},
|
||||
),
|
||||
RichChoice(
|
||||
value="social_unlinked",
|
||||
label="Social Account Unlinked",
|
||||
description="User disconnected a social login provider",
|
||||
metadata={
|
||||
"color": "yellow",
|
||||
"icon": "unlink",
|
||||
"css_class": "text-yellow-600 bg-yellow-50",
|
||||
"severity": "info",
|
||||
"category": "social",
|
||||
"sort_order": 12,
|
||||
},
|
||||
),
|
||||
RichChoice(
|
||||
value="password_reset_requested",
|
||||
label="Password Reset Requested",
|
||||
description="Password reset was requested for user's account",
|
||||
metadata={
|
||||
"color": "yellow",
|
||||
"icon": "key",
|
||||
"css_class": "text-yellow-600 bg-yellow-50",
|
||||
"severity": "info",
|
||||
"category": "password",
|
||||
"sort_order": 13,
|
||||
},
|
||||
),
|
||||
RichChoice(
|
||||
value="password_reset_completed",
|
||||
label="Password Reset Completed",
|
||||
description="User successfully reset their password",
|
||||
metadata={
|
||||
"color": "green",
|
||||
"icon": "key",
|
||||
"css_class": "text-green-600 bg-green-50",
|
||||
"severity": "info",
|
||||
"category": "password",
|
||||
"sort_order": 14,
|
||||
},
|
||||
),
|
||||
RichChoice(
|
||||
value="password_changed",
|
||||
label="Password Changed",
|
||||
description="User changed their password",
|
||||
metadata={
|
||||
"color": "green",
|
||||
"icon": "key",
|
||||
"css_class": "text-green-600 bg-green-50",
|
||||
"severity": "info",
|
||||
"category": "password",
|
||||
"sort_order": 15,
|
||||
},
|
||||
),
|
||||
RichChoice(
|
||||
value="session_invalidated",
|
||||
label="Session Invalidated",
|
||||
description="User's session was terminated",
|
||||
metadata={
|
||||
"color": "yellow",
|
||||
"icon": "clock",
|
||||
"css_class": "text-yellow-600 bg-yellow-50",
|
||||
"severity": "info",
|
||||
"category": "session",
|
||||
"sort_order": 16,
|
||||
},
|
||||
),
|
||||
RichChoice(
|
||||
value="recovery_code_used",
|
||||
label="Recovery Code Used",
|
||||
description="User used a recovery code for authentication",
|
||||
metadata={
|
||||
"color": "orange",
|
||||
"icon": "key",
|
||||
"css_class": "text-orange-600 bg-orange-50",
|
||||
"severity": "warning",
|
||||
"category": "mfa",
|
||||
"sort_order": 17,
|
||||
},
|
||||
),
|
||||
RichChoice(
|
||||
value="recovery_codes_regenerated",
|
||||
label="Recovery Codes Regenerated",
|
||||
description="User generated new recovery codes",
|
||||
metadata={
|
||||
"color": "blue",
|
||||
"icon": "refresh",
|
||||
"css_class": "text-blue-600 bg-blue-50",
|
||||
"severity": "info",
|
||||
"category": "mfa",
|
||||
"sort_order": 18,
|
||||
},
|
||||
),
|
||||
RichChoice(
|
||||
value="session_to_token",
|
||||
label="Passkey Login",
|
||||
description="Signed in using a passkey",
|
||||
metadata={
|
||||
"color": "green",
|
||||
"icon": "fingerprint",
|
||||
"css_class": "text-green-600 bg-green-50",
|
||||
"severity": "info",
|
||||
"category": "authentication",
|
||||
"sort_order": 19,
|
||||
},
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# REGISTER ALL CHOICE GROUPS
|
||||
# =============================================================================
|
||||
@@ -598,3 +856,5 @@ register_choices("privacy_levels", privacy_levels.choices, "accounts", "Privacy
|
||||
register_choices("top_list_categories", top_list_categories.choices, "accounts", "Top list category types")
|
||||
register_choices("notification_types", notification_types.choices, "accounts", "Notification type classifications")
|
||||
register_choices("notification_priorities", notification_priorities.choices, "accounts", "Notification priority levels")
|
||||
register_choices("security_event_types", security_event_types.choices, "accounts", "Security event type classifications")
|
||||
|
||||
|
||||
195
backend/apps/accounts/migrations/0017_add_security_log_model.py
Normal file
195
backend/apps/accounts/migrations/0017_add_security_log_model.py
Normal file
@@ -0,0 +1,195 @@
|
||||
# Generated by Django 5.2.10 on 2026-01-10 20:48
|
||||
|
||||
import apps.core.choices.fields
|
||||
import django.db.models.deletion
|
||||
import pgtrigger.compiler
|
||||
import pgtrigger.migrations
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("accounts", "0016_remove_emailverification_insert_insert_and_more"),
|
||||
("pghistory", "0007_auto_20250421_0444"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="SecurityLog",
|
||||
fields=[
|
||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||
(
|
||||
"event_type",
|
||||
apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
choice_group="security_event_types",
|
||||
choices=[
|
||||
("login_success", "Login Success"),
|
||||
("login_failed", "Login Failed"),
|
||||
("logout", "Logout"),
|
||||
("mfa_enrolled", "MFA Enrolled"),
|
||||
("mfa_disabled", "MFA Disabled"),
|
||||
("mfa_challenge_success", "MFA Challenge Success"),
|
||||
("mfa_challenge_failed", "MFA Challenge Failed"),
|
||||
("passkey_registered", "Passkey Registered"),
|
||||
("passkey_removed", "Passkey Removed"),
|
||||
("passkey_login", "Passkey Login"),
|
||||
("social_linked", "Social Account Linked"),
|
||||
("social_unlinked", "Social Account Unlinked"),
|
||||
("password_reset_requested", "Password Reset Requested"),
|
||||
("password_reset_completed", "Password Reset Completed"),
|
||||
("password_changed", "Password Changed"),
|
||||
("session_invalidated", "Session Invalidated"),
|
||||
("recovery_code_used", "Recovery Code Used"),
|
||||
("recovery_codes_regenerated", "Recovery Codes Regenerated"),
|
||||
],
|
||||
db_index=True,
|
||||
domain="accounts",
|
||||
help_text="Type of security event",
|
||||
max_length=50,
|
||||
),
|
||||
),
|
||||
("ip_address", models.GenericIPAddressField(help_text="IP address of the request")),
|
||||
("user_agent", models.TextField(blank=True, help_text="User agent string from the request")),
|
||||
("metadata", models.JSONField(blank=True, default=dict, help_text="Additional event-specific data")),
|
||||
("created_at", models.DateTimeField(auto_now_add=True, help_text="When this event occurred")),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
help_text="User this event is associated with",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="security_logs",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Security Log",
|
||||
"verbose_name_plural": "Security Logs",
|
||||
"ordering": ["-created_at"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="SecurityLogEvent",
|
||||
fields=[
|
||||
("pgh_id", models.AutoField(primary_key=True, serialize=False)),
|
||||
("pgh_created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("pgh_label", models.TextField(help_text="The event label.")),
|
||||
("id", models.BigIntegerField()),
|
||||
(
|
||||
"event_type",
|
||||
apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
choice_group="security_event_types",
|
||||
choices=[
|
||||
("login_success", "Login Success"),
|
||||
("login_failed", "Login Failed"),
|
||||
("logout", "Logout"),
|
||||
("mfa_enrolled", "MFA Enrolled"),
|
||||
("mfa_disabled", "MFA Disabled"),
|
||||
("mfa_challenge_success", "MFA Challenge Success"),
|
||||
("mfa_challenge_failed", "MFA Challenge Failed"),
|
||||
("passkey_registered", "Passkey Registered"),
|
||||
("passkey_removed", "Passkey Removed"),
|
||||
("passkey_login", "Passkey Login"),
|
||||
("social_linked", "Social Account Linked"),
|
||||
("social_unlinked", "Social Account Unlinked"),
|
||||
("password_reset_requested", "Password Reset Requested"),
|
||||
("password_reset_completed", "Password Reset Completed"),
|
||||
("password_changed", "Password Changed"),
|
||||
("session_invalidated", "Session Invalidated"),
|
||||
("recovery_code_used", "Recovery Code Used"),
|
||||
("recovery_codes_regenerated", "Recovery Codes Regenerated"),
|
||||
],
|
||||
domain="accounts",
|
||||
help_text="Type of security event",
|
||||
max_length=50,
|
||||
),
|
||||
),
|
||||
("ip_address", models.GenericIPAddressField(help_text="IP address of the request")),
|
||||
("user_agent", models.TextField(blank=True, help_text="User agent string from the request")),
|
||||
("metadata", models.JSONField(blank=True, default=dict, help_text="Additional event-specific data")),
|
||||
("created_at", models.DateTimeField(auto_now_add=True, help_text="When this event occurred")),
|
||||
(
|
||||
"pgh_context",
|
||||
models.ForeignKey(
|
||||
db_constraint=False,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
to="pghistory.context",
|
||||
),
|
||||
),
|
||||
(
|
||||
"pgh_obj",
|
||||
models.ForeignKey(
|
||||
db_constraint=False,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="events",
|
||||
to="accounts.securitylog",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
db_constraint=False,
|
||||
help_text="User this event is associated with",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
related_query_name="+",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="securitylog",
|
||||
index=models.Index(fields=["user", "-created_at"], name="accounts_se_user_id_d46023_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="securitylog",
|
||||
index=models.Index(fields=["event_type", "-created_at"], name="accounts_se_event_t_814971_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="securitylog",
|
||||
index=models.Index(fields=["ip_address", "-created_at"], name="accounts_se_ip_addr_2a19c8_idx"),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="securitylog",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="insert_insert",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
func='INSERT INTO "accounts_securitylogevent" ("created_at", "event_type", "id", "ip_address", "metadata", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "user_agent", "user_id") VALUES (NEW."created_at", NEW."event_type", NEW."id", NEW."ip_address", NEW."metadata", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."user_agent", NEW."user_id"); RETURN NULL;',
|
||||
hash="a40cf3f6fa9e8cda99f7204edb226b26bbe03eda",
|
||||
operation="INSERT",
|
||||
pgid="pgtrigger_insert_insert_5d4cf",
|
||||
table="accounts_securitylog",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="securitylog",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="update_update",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
|
||||
func='INSERT INTO "accounts_securitylogevent" ("created_at", "event_type", "id", "ip_address", "metadata", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "user_agent", "user_id") VALUES (NEW."created_at", NEW."event_type", NEW."id", NEW."ip_address", NEW."metadata", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."user_agent", NEW."user_id"); RETURN NULL;',
|
||||
hash="244fc44bdaff1bf2d557f09ae452a9ea77274068",
|
||||
operation="UPDATE",
|
||||
pgid="pgtrigger_update_update_d4645",
|
||||
table="accounts_securitylog",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -620,6 +620,111 @@ class NotificationPreference(TrackedModel):
|
||||
return getattr(self, field_name, False)
|
||||
|
||||
|
||||
@pghistory.track()
|
||||
class SecurityLog(models.Model):
|
||||
"""
|
||||
Model to track security-relevant authentication events.
|
||||
|
||||
All security-critical events are logged here for audit purposes,
|
||||
including logins, MFA changes, password changes, and session management.
|
||||
"""
|
||||
|
||||
user = models.ForeignKey(
|
||||
User,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="security_logs",
|
||||
null=True, # Allow null for failed login attempts with no valid user
|
||||
blank=True,
|
||||
help_text="User this event is associated with",
|
||||
)
|
||||
event_type = RichChoiceField(
|
||||
choice_group="security_event_types",
|
||||
domain="accounts",
|
||||
max_length=50,
|
||||
db_index=True,
|
||||
help_text="Type of security event",
|
||||
)
|
||||
ip_address = models.GenericIPAddressField(
|
||||
help_text="IP address of the request",
|
||||
)
|
||||
user_agent = models.TextField(
|
||||
blank=True,
|
||||
help_text="User agent string from the request",
|
||||
)
|
||||
metadata = models.JSONField(
|
||||
default=dict,
|
||||
blank=True,
|
||||
help_text="Additional event-specific data",
|
||||
)
|
||||
created_at = models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
help_text="When this event occurred",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
indexes = [
|
||||
models.Index(fields=["user", "-created_at"]),
|
||||
models.Index(fields=["event_type", "-created_at"]),
|
||||
models.Index(fields=["ip_address", "-created_at"]),
|
||||
]
|
||||
verbose_name = "Security Log"
|
||||
verbose_name_plural = "Security Logs"
|
||||
|
||||
def __str__(self):
|
||||
username = self.user.username if self.user else "Unknown"
|
||||
return f"{self.get_event_type_display()} - {username} at {self.created_at}"
|
||||
|
||||
@classmethod
|
||||
def log_event(
|
||||
cls,
|
||||
event_type: str,
|
||||
ip_address: str,
|
||||
user=None,
|
||||
user_agent: str = "",
|
||||
metadata: dict = None,
|
||||
) -> "SecurityLog":
|
||||
"""
|
||||
Create a new security log entry.
|
||||
|
||||
Args:
|
||||
event_type: One of security_event_types choices (e.g., "login_success")
|
||||
ip_address: Client IP address
|
||||
user: User instance (optional for failed logins)
|
||||
user_agent: Browser user agent string
|
||||
metadata: Additional event-specific data
|
||||
|
||||
Returns:
|
||||
The created SecurityLog instance
|
||||
"""
|
||||
return cls.objects.create(
|
||||
user=user,
|
||||
event_type=event_type,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
metadata=metadata or {},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_recent_for_user(cls, user, limit: int = 20):
|
||||
"""Get recent security events for a user."""
|
||||
return cls.objects.filter(user=user).order_by("-created_at")[:limit]
|
||||
|
||||
@classmethod
|
||||
def get_failed_login_count(cls, ip_address: str, minutes: int = 15) -> int:
|
||||
"""Count failed login attempts from an IP in the last N minutes."""
|
||||
from datetime import timedelta
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
cutoff = timezone.now() - timedelta(minutes=minutes)
|
||||
return cls.objects.filter(
|
||||
event_type="login_failed",
|
||||
ip_address=ip_address,
|
||||
created_at__gte=cutoff,
|
||||
).count()
|
||||
|
||||
|
||||
# Signal handlers for automatic notification preference creation
|
||||
|
||||
|
||||
|
||||
@@ -8,6 +8,23 @@ including social provider management, user authentication, and profile services.
|
||||
from .account_service import AccountService
|
||||
from .social_provider_service import SocialProviderService
|
||||
from .user_deletion_service import UserDeletionService
|
||||
from .security_service import (
|
||||
get_client_ip,
|
||||
log_security_event,
|
||||
log_security_event_simple,
|
||||
send_security_notification,
|
||||
check_auth_method_availability,
|
||||
invalidate_user_sessions,
|
||||
)
|
||||
|
||||
__all__ = ["AccountService", "SocialProviderService", "UserDeletionService"]
|
||||
|
||||
__all__ = [
|
||||
"AccountService",
|
||||
"SocialProviderService",
|
||||
"UserDeletionService",
|
||||
"get_client_ip",
|
||||
"log_security_event",
|
||||
"log_security_event_simple",
|
||||
"send_security_notification",
|
||||
"check_auth_method_availability",
|
||||
"invalidate_user_sessions",
|
||||
]
|
||||
|
||||
402
backend/apps/accounts/services/security_service.py
Normal file
402
backend/apps/accounts/services/security_service.py
Normal file
@@ -0,0 +1,402 @@
|
||||
"""
|
||||
Security Service for ThrillWiki
|
||||
|
||||
Provides centralized security event logging, notifications, and helper functions
|
||||
for all authentication-related operations.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.mail import send_mail
|
||||
from django.template.loader import render_to_string
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_client_ip(request) -> str:
|
||||
"""
|
||||
Extract client IP from request, handling proxies correctly.
|
||||
|
||||
Args:
|
||||
request: Django/DRF request object
|
||||
|
||||
Returns:
|
||||
Client IP address as string
|
||||
"""
|
||||
# Check for proxy headers first
|
||||
x_forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR")
|
||||
if x_forwarded_for:
|
||||
# X-Forwarded-For can contain multiple IPs; take the first one
|
||||
return x_forwarded_for.split(",")[0].strip()
|
||||
|
||||
# Check for Cloudflare's CF-Connecting-IP header
|
||||
cf_connecting_ip = request.META.get("HTTP_CF_CONNECTING_IP")
|
||||
if cf_connecting_ip:
|
||||
return cf_connecting_ip
|
||||
|
||||
# Fallback to REMOTE_ADDR
|
||||
return request.META.get("REMOTE_ADDR", "0.0.0.0")
|
||||
|
||||
|
||||
def log_security_event(
|
||||
event_type: str,
|
||||
request,
|
||||
user=None,
|
||||
metadata: dict = None
|
||||
) -> Any:
|
||||
"""
|
||||
Log a security event with request context.
|
||||
|
||||
Args:
|
||||
event_type: One of SecurityLog.EventType choices
|
||||
request: Django/DRF request object
|
||||
user: User instance (optional for failed logins)
|
||||
metadata: Additional event-specific data
|
||||
|
||||
Returns:
|
||||
The created SecurityLog instance
|
||||
"""
|
||||
from apps.accounts.models import SecurityLog
|
||||
|
||||
try:
|
||||
return SecurityLog.log_event(
|
||||
event_type=event_type,
|
||||
ip_address=get_client_ip(request),
|
||||
user=user,
|
||||
user_agent=request.META.get("HTTP_USER_AGENT", ""),
|
||||
metadata=metadata or {},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to log security event {event_type}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def log_security_event_simple(
|
||||
event_type: str,
|
||||
ip_address: str,
|
||||
user=None,
|
||||
user_agent: str = "",
|
||||
metadata: dict = None
|
||||
) -> Any:
|
||||
"""
|
||||
Log a security event without request context.
|
||||
|
||||
Use this when you don't have access to the request object.
|
||||
|
||||
Args:
|
||||
event_type: One of SecurityLog.EventType choices
|
||||
ip_address: Client IP address
|
||||
user: User instance (optional)
|
||||
user_agent: Browser user agent string
|
||||
metadata: Additional event-specific data
|
||||
|
||||
Returns:
|
||||
The created SecurityLog instance
|
||||
"""
|
||||
from apps.accounts.models import SecurityLog
|
||||
|
||||
try:
|
||||
return SecurityLog.log_event(
|
||||
event_type=event_type,
|
||||
ip_address=ip_address,
|
||||
user=user,
|
||||
user_agent=user_agent,
|
||||
metadata=metadata or {},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to log security event {event_type}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
# Subject line mapping for security notifications
|
||||
SECURITY_NOTIFICATION_SUBJECTS = {
|
||||
"mfa_enrolled": "Two-Factor Authentication Enabled",
|
||||
"mfa_disabled": "Two-Factor Authentication Disabled",
|
||||
"passkey_registered": "New Passkey Added to Your Account",
|
||||
"passkey_removed": "Passkey Removed from Your Account",
|
||||
"password_changed": "Your Password Was Changed",
|
||||
"password_reset_completed": "Your Password Has Been Reset",
|
||||
"social_linked": "Social Account Connected",
|
||||
"social_unlinked": "Social Account Disconnected",
|
||||
"session_invalidated": "Session Security Update",
|
||||
"recovery_codes_regenerated": "Recovery Codes Regenerated",
|
||||
}
|
||||
|
||||
|
||||
def send_security_notification(
|
||||
user,
|
||||
event_type: str,
|
||||
metadata: dict = None
|
||||
) -> bool:
|
||||
"""
|
||||
Send email notification for security-sensitive events.
|
||||
|
||||
This function sends an email to the user when important security
|
||||
events occur on their account.
|
||||
|
||||
Args:
|
||||
user: User instance to notify
|
||||
event_type: Type of security event (used to select template and subject)
|
||||
metadata: Additional context for the email template
|
||||
|
||||
Returns:
|
||||
True if email was sent successfully, False otherwise
|
||||
"""
|
||||
if not user or not user.email:
|
||||
logger.warning(f"Cannot send security notification: no email for user")
|
||||
return False
|
||||
|
||||
# Check if user has security notifications enabled
|
||||
if hasattr(user, "notification_preference"):
|
||||
prefs = user.notification_preference
|
||||
if not getattr(prefs, "account_security_email", True):
|
||||
logger.debug(f"User {user.username} has security emails disabled")
|
||||
return False
|
||||
|
||||
try:
|
||||
subject = f"ThrillWiki Security Alert: {SECURITY_NOTIFICATION_SUBJECTS.get(event_type, 'Account Activity')}"
|
||||
|
||||
context = {
|
||||
"user": user,
|
||||
"event_type": event_type,
|
||||
"event_display": SECURITY_NOTIFICATION_SUBJECTS.get(event_type, "Account Activity"),
|
||||
"metadata": metadata or {},
|
||||
"site_name": "ThrillWiki",
|
||||
"support_email": getattr(settings, "DEFAULT_SUPPORT_EMAIL", "support@thrillwiki.com"),
|
||||
}
|
||||
|
||||
# Try to render HTML template, fallback to plain text
|
||||
try:
|
||||
html_message = render_to_string("accounts/email/security_notification.html", context)
|
||||
except Exception as template_error:
|
||||
logger.debug(f"HTML template not found, using fallback: {template_error}")
|
||||
html_message = _get_fallback_security_email(context)
|
||||
|
||||
# Plain text version
|
||||
text_message = _get_plain_text_security_email(context)
|
||||
|
||||
send_mail(
|
||||
subject=subject,
|
||||
message=text_message,
|
||||
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||
recipient_list=[user.email],
|
||||
html_message=html_message,
|
||||
fail_silently=False,
|
||||
)
|
||||
|
||||
logger.info(f"Security notification sent to {user.email} for event: {event_type}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send security notification to {user.email}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def _get_plain_text_security_email(context: dict) -> str:
|
||||
"""Generate plain text email for security notifications."""
|
||||
event_display = context.get("event_display", "Account Activity")
|
||||
user = context.get("user")
|
||||
metadata = context.get("metadata", {})
|
||||
|
||||
lines = [
|
||||
f"Hello {user.get_display_name() if user else 'User'},",
|
||||
"",
|
||||
f"This is a security notification from ThrillWiki.",
|
||||
"",
|
||||
f"Event: {event_display}",
|
||||
]
|
||||
|
||||
# Add metadata details
|
||||
if metadata:
|
||||
lines.append("")
|
||||
lines.append("Details:")
|
||||
for key, value in metadata.items():
|
||||
if key not in ("user_id", "internal"):
|
||||
lines.append(f" - {key.replace('_', ' ').title()}: {value}")
|
||||
|
||||
lines.extend([
|
||||
"",
|
||||
"If you did not perform this action, please secure your account immediately:",
|
||||
"1. Change your password",
|
||||
"2. Review your connected devices and sign out any you don't recognize",
|
||||
"3. Contact support if you need assistance",
|
||||
"",
|
||||
"Best regards,",
|
||||
"The ThrillWiki Team",
|
||||
])
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _get_fallback_security_email(context: dict) -> str:
|
||||
"""Generate HTML email for security notifications when template not found."""
|
||||
event_display = context.get("event_display", "Account Activity")
|
||||
user = context.get("user")
|
||||
metadata = context.get("metadata", {})
|
||||
|
||||
metadata_html = ""
|
||||
if metadata:
|
||||
items = []
|
||||
for key, value in metadata.items():
|
||||
if key not in ("user_id", "internal"):
|
||||
items.append(f"<li><strong>{key.replace('_', ' ').title()}:</strong> {value}</li>")
|
||||
if items:
|
||||
metadata_html = f"<h3>Details:</h3><ul>{''.join(items)}</ul>"
|
||||
|
||||
return f"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; }}
|
||||
.container {{ max-width: 600px; margin: 0 auto; padding: 20px; }}
|
||||
.header {{ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 20px; border-radius: 8px 8px 0 0; }}
|
||||
.header h1 {{ color: white; margin: 0; font-size: 24px; }}
|
||||
.content {{ background: #f9f9f9; padding: 30px; border-radius: 0 0 8px 8px; }}
|
||||
.alert {{ background: #fff3cd; border-left: 4px solid #ffc107; padding: 15px; margin: 20px 0; }}
|
||||
.footer {{ text-align: center; color: #666; font-size: 12px; margin-top: 20px; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>🔒 Security Alert</h1>
|
||||
</div>
|
||||
<div class="content">
|
||||
<p>Hello {user.get_display_name() if user else 'User'},</p>
|
||||
<p>This is a security notification from ThrillWiki.</p>
|
||||
<h2>{event_display}</h2>
|
||||
{metadata_html}
|
||||
<div class="alert">
|
||||
<strong>Didn't do this?</strong><br>
|
||||
If you did not perform this action, please secure your account immediately by changing your password and reviewing your connected devices.
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>This is an automated security notification from ThrillWiki.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
def check_auth_method_availability(user) -> dict:
|
||||
"""
|
||||
Check what authentication methods a user has available.
|
||||
|
||||
This is used to prevent users from removing their last auth method.
|
||||
|
||||
Args:
|
||||
user: User instance to check
|
||||
|
||||
Returns:
|
||||
Dictionary with auth method availability:
|
||||
{
|
||||
"has_password": bool,
|
||||
"has_totp": bool,
|
||||
"has_passkey": bool,
|
||||
"passkey_count": int,
|
||||
"has_social": bool,
|
||||
"social_providers": list[str],
|
||||
"total_methods": int,
|
||||
"can_remove_mfa": bool,
|
||||
"can_remove_passkey": bool,
|
||||
"can_remove_social": bool,
|
||||
}
|
||||
"""
|
||||
try:
|
||||
from allauth.mfa.models import Authenticator
|
||||
except ImportError:
|
||||
Authenticator = None
|
||||
|
||||
result = {
|
||||
"has_password": user.has_usable_password(),
|
||||
"has_totp": False,
|
||||
"has_passkey": False,
|
||||
"passkey_count": 0,
|
||||
"has_social": False,
|
||||
"social_providers": [],
|
||||
"total_methods": 0,
|
||||
}
|
||||
|
||||
# Check MFA authenticators
|
||||
if Authenticator:
|
||||
result["has_totp"] = Authenticator.objects.filter(
|
||||
user=user, type=Authenticator.Type.TOTP
|
||||
).exists()
|
||||
|
||||
passkey_count = Authenticator.objects.filter(
|
||||
user=user, type=Authenticator.Type.WEBAUTHN
|
||||
).count()
|
||||
result["passkey_count"] = passkey_count
|
||||
result["has_passkey"] = passkey_count > 0
|
||||
|
||||
# Check social accounts
|
||||
if hasattr(user, "socialaccount_set"):
|
||||
social_accounts = user.socialaccount_set.all()
|
||||
result["has_social"] = social_accounts.exists()
|
||||
result["social_providers"] = list(social_accounts.values_list("provider", flat=True))
|
||||
|
||||
# Calculate total methods (counting passkeys as one method regardless of count)
|
||||
result["total_methods"] = sum([
|
||||
result["has_password"],
|
||||
result["has_passkey"],
|
||||
result["has_social"],
|
||||
])
|
||||
|
||||
# Determine what can be safely removed
|
||||
# User must always have at least one primary auth method remaining
|
||||
result["can_remove_mfa"] = result["total_methods"] >= 1
|
||||
result["can_remove_passkey"] = (
|
||||
result["total_methods"] > 1 or
|
||||
(result["passkey_count"] > 1) or
|
||||
result["has_password"] or
|
||||
result["has_social"]
|
||||
)
|
||||
result["can_remove_social"] = (
|
||||
result["total_methods"] > 1 or
|
||||
result["has_password"] or
|
||||
result["has_passkey"]
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def invalidate_user_sessions(user, exclude_current: bool = False, request=None) -> int:
|
||||
"""
|
||||
Invalidate all JWT tokens for a user.
|
||||
|
||||
This is used after security-sensitive operations like password reset.
|
||||
|
||||
Args:
|
||||
user: User whose sessions to invalidate
|
||||
exclude_current: If True and request is provided, keep current session
|
||||
request: Current request (used if exclude_current is True)
|
||||
|
||||
Returns:
|
||||
Number of tokens invalidated
|
||||
"""
|
||||
try:
|
||||
from rest_framework_simplejwt.token_blacklist.models import (
|
||||
BlacklistedToken,
|
||||
OutstandingToken,
|
||||
)
|
||||
except ImportError:
|
||||
logger.warning("JWT token blacklist not available")
|
||||
return 0
|
||||
|
||||
count = 0
|
||||
outstanding_tokens = OutstandingToken.objects.filter(user=user)
|
||||
|
||||
for token in outstanding_tokens:
|
||||
try:
|
||||
BlacklistedToken.objects.get_or_create(token=token)
|
||||
count += 1
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not blacklist token: {e}")
|
||||
|
||||
logger.info(f"Invalidated {count} tokens for user {user.username}")
|
||||
return count
|
||||
@@ -904,6 +904,12 @@ def list_profiles(request):
|
||||
is_active=True,
|
||||
).select_related("profile").order_by("-date_joined")
|
||||
|
||||
# User ID filter - EXACT match (critical for single user lookups)
|
||||
user_id = request.query_params.get("user_id", "").strip()
|
||||
if user_id:
|
||||
# Use exact match to prevent user_id=4 from matching user_id=4448
|
||||
queryset = queryset.filter(user_id=user_id)
|
||||
|
||||
# Search filter
|
||||
search = request.query_params.get("search", "").strip()
|
||||
if search:
|
||||
@@ -1081,18 +1087,53 @@ def update_user_profile(request):
|
||||
@extend_schema(
|
||||
operation_id="get_user_preferences",
|
||||
summary="Get user preferences",
|
||||
description="Get the authenticated user's preferences and settings.",
|
||||
description="Get or update the authenticated user's preferences and settings.",
|
||||
responses={
|
||||
200: UserPreferencesSerializer,
|
||||
401: {"description": "Authentication required"},
|
||||
},
|
||||
tags=["User Settings"],
|
||||
)
|
||||
@api_view(["GET"])
|
||||
@api_view(["GET", "PATCH"])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def get_user_preferences(request):
|
||||
"""Get user preferences."""
|
||||
"""Get or update user preferences."""
|
||||
user = request.user
|
||||
|
||||
if request.method == "PATCH":
|
||||
current_data = {
|
||||
"theme_preference": user.theme_preference,
|
||||
"email_notifications": user.email_notifications,
|
||||
"push_notifications": user.push_notifications,
|
||||
"privacy_level": user.privacy_level,
|
||||
"show_email": user.show_email,
|
||||
"show_real_name": user.show_real_name,
|
||||
"show_statistics": user.show_statistics,
|
||||
"allow_friend_requests": user.allow_friend_requests,
|
||||
"allow_messages": user.allow_messages,
|
||||
}
|
||||
|
||||
# Handle moderation_preferences field (stored as JSON on User model if it exists)
|
||||
if "moderation_preferences" in request.data:
|
||||
try:
|
||||
if hasattr(user, 'moderation_preferences'):
|
||||
user.moderation_preferences = request.data["moderation_preferences"]
|
||||
user.save()
|
||||
# Return success even if field doesn't exist (non-critical preference)
|
||||
return Response({"moderation_preferences": request.data["moderation_preferences"]}, status=status.HTTP_200_OK)
|
||||
except Exception:
|
||||
# Non-critical - just return success
|
||||
return Response({"moderation_preferences": request.data["moderation_preferences"]}, status=status.HTTP_200_OK)
|
||||
|
||||
serializer = UserPreferencesSerializer(data={**current_data, **request.data})
|
||||
if serializer.is_valid():
|
||||
for field, value in serializer.validated_data.items():
|
||||
setattr(user, field, value)
|
||||
user.save()
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# GET request
|
||||
data = {
|
||||
"theme_preference": user.theme_preference,
|
||||
"email_notifications": user.email_notifications,
|
||||
|
||||
@@ -370,6 +370,118 @@ def revoke_session(request, session_id):
|
||||
return Response({"detail": "Session revoked"})
|
||||
|
||||
|
||||
# ============== PASSWORD CHANGE ENDPOINT ==============
|
||||
|
||||
# ============== SECURITY LOG ENDPOINT ==============
|
||||
|
||||
@extend_schema(
|
||||
operation_id="get_security_log",
|
||||
summary="Get security activity log",
|
||||
description="Returns paginated list of security events for the current user.",
|
||||
parameters=[
|
||||
{
|
||||
"name": "page",
|
||||
"in": "query",
|
||||
"description": "Page number (1-indexed)",
|
||||
"required": False,
|
||||
"schema": {"type": "integer", "default": 1},
|
||||
},
|
||||
{
|
||||
"name": "page_size",
|
||||
"in": "query",
|
||||
"description": "Number of items per page (max 50)",
|
||||
"required": False,
|
||||
"schema": {"type": "integer", "default": 20},
|
||||
},
|
||||
{
|
||||
"name": "event_type",
|
||||
"in": "query",
|
||||
"description": "Filter by event type",
|
||||
"required": False,
|
||||
"schema": {"type": "string"},
|
||||
},
|
||||
],
|
||||
responses={
|
||||
200: {
|
||||
"description": "Security log entries",
|
||||
"example": {
|
||||
"count": 42,
|
||||
"page": 1,
|
||||
"page_size": 20,
|
||||
"total_pages": 3,
|
||||
"results": [
|
||||
{
|
||||
"id": 1,
|
||||
"event_type": "login_success",
|
||||
"event_type_display": "Login Success",
|
||||
"ip_address": "192.168.1.1",
|
||||
"user_agent": "Mozilla/5.0...",
|
||||
"created_at": "2026-01-06T12:00:00Z",
|
||||
"metadata": {},
|
||||
}
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
tags=["Account"],
|
||||
)
|
||||
@api_view(["GET"])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def get_security_log(request):
|
||||
"""Get security activity log for the current user."""
|
||||
from apps.accounts.models import SecurityLog
|
||||
|
||||
user = request.user
|
||||
|
||||
# Parse pagination params
|
||||
try:
|
||||
page = max(1, int(request.query_params.get("page", 1)))
|
||||
except (ValueError, TypeError):
|
||||
page = 1
|
||||
|
||||
try:
|
||||
page_size = min(50, max(1, int(request.query_params.get("page_size", 20))))
|
||||
except (ValueError, TypeError):
|
||||
page_size = 20
|
||||
|
||||
event_type = request.query_params.get("event_type")
|
||||
|
||||
# Build queryset
|
||||
queryset = SecurityLog.objects.filter(user=user).order_by("-created_at")
|
||||
|
||||
if event_type:
|
||||
queryset = queryset.filter(event_type=event_type)
|
||||
|
||||
# Count total
|
||||
total_count = queryset.count()
|
||||
total_pages = (total_count + page_size - 1) // page_size
|
||||
|
||||
# Fetch page
|
||||
offset = (page - 1) * page_size
|
||||
logs = queryset[offset : offset + page_size]
|
||||
|
||||
# Serialize
|
||||
results = []
|
||||
for log in logs:
|
||||
results.append({
|
||||
"id": log.id,
|
||||
"event_type": log.event_type,
|
||||
"event_type_display": log.get_event_type_display(),
|
||||
"ip_address": log.ip_address,
|
||||
"user_agent": log.user_agent[:200] if log.user_agent else "", # Truncate for safety
|
||||
"created_at": log.created_at.isoformat(),
|
||||
"metadata": log.metadata or {},
|
||||
})
|
||||
|
||||
return Response({
|
||||
"count": total_count,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"total_pages": total_pages,
|
||||
"results": results,
|
||||
})
|
||||
|
||||
|
||||
# ============== PASSWORD CHANGE ENDPOINT ==============
|
||||
|
||||
@extend_schema(
|
||||
@@ -396,6 +508,12 @@ def revoke_session(request, session_id):
|
||||
@permission_classes([IsAuthenticated])
|
||||
def change_password(request):
|
||||
"""Change user password."""
|
||||
from apps.accounts.services.security_service import (
|
||||
log_security_event,
|
||||
send_security_notification,
|
||||
invalidate_user_sessions,
|
||||
)
|
||||
|
||||
user = request.user
|
||||
current_password = request.data.get("current_password", "")
|
||||
new_password = request.data.get("new_password", "")
|
||||
@@ -413,6 +531,24 @@ def change_password(request):
|
||||
)
|
||||
|
||||
user.set_password(new_password)
|
||||
user.last_password_change = timezone.now()
|
||||
user.save()
|
||||
|
||||
return Response({"detail": "Password changed successfully"})
|
||||
# Invalidate all existing sessions/tokens (except current)
|
||||
invalidated_count = invalidate_user_sessions(user, exclude_current=True, request=request)
|
||||
|
||||
# Log security event
|
||||
log_security_event(
|
||||
"password_changed",
|
||||
request,
|
||||
user=user,
|
||||
metadata={"sessions_invalidated": invalidated_count},
|
||||
)
|
||||
|
||||
# Send security notification email
|
||||
send_security_notification(user, "password_changed", metadata={})
|
||||
|
||||
return Response({
|
||||
"detail": "Password changed successfully",
|
||||
"sessions_invalidated": invalidated_count,
|
||||
})
|
||||
|
||||
96
backend/apps/api/v1/auth/jwt.py
Normal file
96
backend/apps/api/v1/auth/jwt.py
Normal file
@@ -0,0 +1,96 @@
|
||||
"""
|
||||
Custom JWT Token Generation for ThrillWiki
|
||||
|
||||
This module provides custom JWT token generation that includes authentication
|
||||
method claims for enhanced MFA satisfaction logic.
|
||||
|
||||
Claims added:
|
||||
- auth_method: How the user authenticated (password, passkey, totp, google, discord)
|
||||
- mfa_verified: Whether MFA was verified during this login
|
||||
- provider_mfa: Whether the OAuth provider (Discord) has MFA enabled
|
||||
"""
|
||||
|
||||
from typing import Literal, TypedDict
|
||||
|
||||
from rest_framework_simplejwt.tokens import RefreshToken
|
||||
|
||||
# Type definitions for auth methods
|
||||
AuthMethod = Literal["password", "passkey", "totp", "google", "discord"]
|
||||
|
||||
|
||||
class TokenClaims(TypedDict, total=False):
|
||||
"""Type definition for custom JWT claims."""
|
||||
|
||||
auth_method: AuthMethod
|
||||
mfa_verified: bool
|
||||
provider_mfa: bool
|
||||
|
||||
|
||||
def create_tokens_for_user(
|
||||
user,
|
||||
auth_method: AuthMethod = "password",
|
||||
mfa_verified: bool = False,
|
||||
provider_mfa: bool = False,
|
||||
) -> dict[str, str]:
|
||||
"""
|
||||
Generate JWT tokens with custom authentication claims.
|
||||
|
||||
Args:
|
||||
user: The Django user object
|
||||
auth_method: How the user authenticated
|
||||
mfa_verified: True if MFA (TOTP/passkey) was verified at login
|
||||
provider_mfa: True if OAuth provider (Discord) has MFA enabled
|
||||
|
||||
Returns:
|
||||
Dictionary with 'access' and 'refresh' token strings
|
||||
"""
|
||||
refresh = RefreshToken.for_user(user)
|
||||
|
||||
# Add custom claims to both refresh and access tokens
|
||||
refresh["auth_method"] = auth_method
|
||||
refresh["mfa_verified"] = mfa_verified
|
||||
refresh["provider_mfa"] = provider_mfa
|
||||
|
||||
access = refresh.access_token
|
||||
|
||||
return {
|
||||
"access": str(access),
|
||||
"refresh": str(refresh),
|
||||
}
|
||||
|
||||
|
||||
def get_auth_method_for_provider(provider: str) -> AuthMethod:
|
||||
"""
|
||||
Map OAuth provider name to AuthMethod type.
|
||||
|
||||
Args:
|
||||
provider: The provider name (e.g., 'google', 'discord')
|
||||
|
||||
Returns:
|
||||
The corresponding AuthMethod
|
||||
"""
|
||||
provider_map: dict[str, AuthMethod] = {
|
||||
"google": "google",
|
||||
"discord": "discord",
|
||||
}
|
||||
return provider_map.get(provider, "password")
|
||||
|
||||
|
||||
def get_provider_mfa_status(provider: str, extra_data: dict) -> bool:
|
||||
"""
|
||||
Extract MFA status from OAuth provider extra_data.
|
||||
|
||||
Only Discord exposes mfa_enabled. Google does not share this info.
|
||||
|
||||
Args:
|
||||
provider: The OAuth provider name
|
||||
extra_data: The extra_data dict from SocialAccount
|
||||
|
||||
Returns:
|
||||
True if provider has MFA enabled, False otherwise
|
||||
"""
|
||||
if provider == "discord":
|
||||
return extra_data.get("mfa_enabled", False)
|
||||
|
||||
# Google and other providers don't expose MFA status
|
||||
return False
|
||||
@@ -64,6 +64,23 @@ def get_mfa_status(request):
|
||||
except Authenticator.DoesNotExist:
|
||||
pass
|
||||
|
||||
# Check for Discord social account with MFA enabled
|
||||
discord_mfa_enabled = False
|
||||
connected_provider = None
|
||||
|
||||
try:
|
||||
social_accounts = user.socialaccount_set.all()
|
||||
for social_account in social_accounts:
|
||||
if social_account.provider == "discord":
|
||||
connected_provider = "discord"
|
||||
discord_mfa_enabled = social_account.extra_data.get("mfa_enabled", False)
|
||||
break
|
||||
elif social_account.provider == "google":
|
||||
connected_provider = "google"
|
||||
# Google doesn't expose MFA status
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# has_second_factor is True if user has either TOTP or Passkey configured
|
||||
has_second_factor = totp_enabled or passkey_enabled
|
||||
|
||||
@@ -76,6 +93,9 @@ def get_mfa_status(request):
|
||||
"recovery_codes_enabled": recovery_enabled,
|
||||
"recovery_codes_count": recovery_count,
|
||||
"has_second_factor": has_second_factor,
|
||||
# New fields for enhanced MFA satisfaction
|
||||
"discord_mfa_enabled": discord_mfa_enabled,
|
||||
"connected_provider": connected_provider,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -100,6 +120,8 @@ def get_mfa_status(request):
|
||||
@permission_classes([IsAuthenticated])
|
||||
def setup_totp(request):
|
||||
"""Generate TOTP secret and QR code for setup."""
|
||||
from django.utils import timezone
|
||||
|
||||
from allauth.mfa.totp.internal import auth as totp_auth
|
||||
|
||||
user = request.user
|
||||
@@ -120,14 +142,16 @@ def setup_totp(request):
|
||||
qr.save(buffer, format="PNG")
|
||||
qr_code_base64 = f"data:image/png;base64,{base64.b64encode(buffer.getvalue()).decode()}"
|
||||
|
||||
# Store secret in session for later verification
|
||||
# Store secret in session for later verification with 15-minute expiry
|
||||
request.session["pending_totp_secret"] = secret
|
||||
request.session["pending_totp_expires"] = (timezone.now().timestamp() + 900) # 15 minutes
|
||||
|
||||
return Response(
|
||||
{
|
||||
"secret": secret,
|
||||
"provisioning_uri": uri,
|
||||
"qr_code_base64": qr_code_base64,
|
||||
"expires_in_seconds": 900,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -165,10 +189,17 @@ def setup_totp(request):
|
||||
@permission_classes([IsAuthenticated])
|
||||
def activate_totp(request):
|
||||
"""Verify TOTP code and activate MFA."""
|
||||
from django.utils import timezone
|
||||
|
||||
from allauth.mfa.models import Authenticator
|
||||
from allauth.mfa.recovery_codes.internal.auth import RecoveryCodes
|
||||
from allauth.mfa.totp.internal import auth as totp_auth
|
||||
|
||||
from apps.accounts.services.security_service import (
|
||||
log_security_event,
|
||||
send_security_notification,
|
||||
)
|
||||
|
||||
user = request.user
|
||||
code = request.data.get("code", "").strip()
|
||||
|
||||
@@ -187,6 +218,19 @@ def activate_totp(request):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Check if setup has expired (15 minute timeout)
|
||||
expires_at = request.session.get("pending_totp_expires")
|
||||
if expires_at and timezone.now().timestamp() > expires_at:
|
||||
# Clear expired session data
|
||||
if "pending_totp_secret" in request.session:
|
||||
del request.session["pending_totp_secret"]
|
||||
if "pending_totp_expires" in request.session:
|
||||
del request.session["pending_totp_expires"]
|
||||
return Response(
|
||||
{"detail": "TOTP setup session expired. Please start setup again."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Verify the code
|
||||
if not totp_auth.validate_totp_code(secret, code):
|
||||
return Response(
|
||||
@@ -215,11 +259,25 @@ def activate_totp(request):
|
||||
# Clear session (only if it exists - won't exist with JWT auth + secret from body)
|
||||
if "pending_totp_secret" in request.session:
|
||||
del request.session["pending_totp_secret"]
|
||||
if "pending_totp_expires" in request.session:
|
||||
del request.session["pending_totp_expires"]
|
||||
|
||||
# Log security event
|
||||
log_security_event(
|
||||
"mfa_enrolled",
|
||||
request,
|
||||
user=user,
|
||||
metadata={"method": "totp"},
|
||||
)
|
||||
|
||||
# Send security notification email
|
||||
send_security_notification(user, "mfa_enrolled", {"method": "TOTP Authenticator"})
|
||||
|
||||
return Response(
|
||||
{
|
||||
"detail": "Two-factor authentication enabled",
|
||||
"recovery_codes": codes,
|
||||
"recovery_codes_count": len(codes),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -255,13 +313,59 @@ def deactivate_totp(request):
|
||||
"""Disable TOTP authentication."""
|
||||
from allauth.mfa.models import Authenticator
|
||||
|
||||
from apps.accounts.services.security_service import (
|
||||
check_auth_method_availability,
|
||||
log_security_event,
|
||||
send_security_notification,
|
||||
)
|
||||
|
||||
user = request.user
|
||||
password = request.data.get("password", "")
|
||||
recovery_code = request.data.get("recovery_code", "")
|
||||
|
||||
# Verify password
|
||||
if not user.check_password(password):
|
||||
# Check if user has other auth methods before we allow disabling MFA
|
||||
auth_methods = check_auth_method_availability(user)
|
||||
|
||||
# If TOTP is their only way in alongside passkeys, we need to ensure they have
|
||||
# at least password or social login to fall back on
|
||||
if not auth_methods["has_password"] and not auth_methods["has_social"] and not auth_methods["has_passkey"]:
|
||||
return Response(
|
||||
{"detail": "Invalid password"},
|
||||
{"detail": "Cannot disable MFA: you must have at least one authentication method. Please set a password or connect a social account first."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Verify password OR recovery code
|
||||
verified = False
|
||||
verification_method = None
|
||||
|
||||
if password and user.check_password(password):
|
||||
verified = True
|
||||
verification_method = "password"
|
||||
elif recovery_code:
|
||||
# Try to verify with recovery code
|
||||
try:
|
||||
recovery_auth = Authenticator.objects.get(
|
||||
user=user, type=Authenticator.Type.RECOVERY_CODES
|
||||
)
|
||||
unused_codes = recovery_auth.data.get("codes", [])
|
||||
if recovery_code.upper().replace("-", "").replace(" ", "") in [
|
||||
c.upper().replace("-", "").replace(" ", "") for c in unused_codes
|
||||
]:
|
||||
verified = True
|
||||
verification_method = "recovery_code"
|
||||
# Remove the used code
|
||||
unused_codes = [
|
||||
c for c in unused_codes
|
||||
if c.upper().replace("-", "").replace(" ", "") != recovery_code.upper().replace("-", "").replace(" ", "")
|
||||
]
|
||||
recovery_auth.data["codes"] = unused_codes
|
||||
recovery_auth.save()
|
||||
except Authenticator.DoesNotExist:
|
||||
pass
|
||||
|
||||
if not verified:
|
||||
return Response(
|
||||
{"detail": "Invalid password or recovery code"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
@@ -276,6 +380,17 @@ def deactivate_totp(request):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Log security event
|
||||
log_security_event(
|
||||
"mfa_disabled",
|
||||
request,
|
||||
user=user,
|
||||
metadata={"method": "totp", "verified_via": verification_method},
|
||||
)
|
||||
|
||||
# Send security notification email
|
||||
send_security_notification(user, "mfa_disabled", {"method": "TOTP Authenticator"})
|
||||
|
||||
return Response(
|
||||
{
|
||||
"detail": "Two-factor authentication disabled",
|
||||
@@ -361,6 +476,11 @@ def regenerate_recovery_codes(request):
|
||||
from allauth.mfa.models import Authenticator
|
||||
from allauth.mfa.recovery_codes.internal.auth import RecoveryCodes
|
||||
|
||||
from apps.accounts.services.security_service import (
|
||||
log_security_event,
|
||||
send_security_notification,
|
||||
)
|
||||
|
||||
user = request.user
|
||||
password = request.data.get("password", "")
|
||||
|
||||
@@ -371,8 +491,11 @@ def regenerate_recovery_codes(request):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Check if TOTP is enabled
|
||||
if not Authenticator.objects.filter(user=user, type=Authenticator.Type.TOTP).exists():
|
||||
# Check if MFA is enabled (TOTP or Passkey)
|
||||
has_totp = Authenticator.objects.filter(user=user, type=Authenticator.Type.TOTP).exists()
|
||||
has_passkey = Authenticator.objects.filter(user=user, type=Authenticator.Type.WEBAUTHN).exists()
|
||||
|
||||
if not has_totp and not has_passkey:
|
||||
return Response(
|
||||
{"detail": "Two-factor authentication is not enabled"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
@@ -387,9 +510,21 @@ def regenerate_recovery_codes(request):
|
||||
recovery_instance = RecoveryCodes.activate(user)
|
||||
codes = recovery_instance.get_unused_codes()
|
||||
|
||||
# Log security event
|
||||
log_security_event(
|
||||
"recovery_codes_regenerated",
|
||||
request,
|
||||
user=user,
|
||||
metadata={"codes_generated": len(codes)},
|
||||
)
|
||||
|
||||
# Send security notification email
|
||||
send_security_notification(user, "recovery_codes_regenerated", {"codes_generated": len(codes)})
|
||||
|
||||
return Response(
|
||||
{
|
||||
"success": True,
|
||||
"recovery_codes": codes,
|
||||
"recovery_codes_count": len(codes),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -10,7 +10,7 @@ import logging
|
||||
from drf_spectacular.utils import extend_schema
|
||||
from rest_framework import status
|
||||
from rest_framework.decorators import api_view, permission_classes
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.permissions import AllowAny, IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -93,6 +93,7 @@ def get_passkey_status(request):
|
||||
def get_registration_options(request):
|
||||
"""Get WebAuthn registration options for passkey setup."""
|
||||
try:
|
||||
from django.utils import timezone
|
||||
from allauth.mfa.webauthn.internal import auth as webauthn_auth
|
||||
|
||||
# Use the correct allauth API: begin_registration
|
||||
@@ -101,8 +102,17 @@ def get_registration_options(request):
|
||||
|
||||
# State is stored internally by begin_registration via set_state()
|
||||
|
||||
# Store registration timeout in session (5 minutes)
|
||||
request.session["pending_passkey_expires"] = timezone.now().timestamp() + 300 # 5 minutes
|
||||
|
||||
# Debug log the structure
|
||||
logger.debug(f"WebAuthn registration options type: {type(creation_options)}")
|
||||
logger.debug(f"WebAuthn registration options keys: {creation_options.keys() if isinstance(creation_options, dict) else 'not a dict'}")
|
||||
logger.info(f"WebAuthn registration options: {creation_options}")
|
||||
|
||||
return Response({
|
||||
"options": creation_options,
|
||||
"expires_in_seconds": 300,
|
||||
})
|
||||
except ImportError as e:
|
||||
logger.error(f"WebAuthn module import error: {e}")
|
||||
@@ -143,8 +153,14 @@ def get_registration_options(request):
|
||||
def register_passkey(request):
|
||||
"""Complete passkey registration with WebAuthn response."""
|
||||
try:
|
||||
from django.utils import timezone
|
||||
from allauth.mfa.webauthn.internal import auth as webauthn_auth
|
||||
|
||||
from apps.accounts.services.security_service import (
|
||||
log_security_event,
|
||||
send_security_notification,
|
||||
)
|
||||
|
||||
credential = request.data.get("credential")
|
||||
name = request.data.get("name", "Passkey")
|
||||
|
||||
@@ -154,6 +170,17 @@ def register_passkey(request):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Check if registration has expired (5 minute timeout)
|
||||
expires_at = request.session.get("pending_passkey_expires")
|
||||
if expires_at and timezone.now().timestamp() > expires_at:
|
||||
# Clear expired session data
|
||||
if "pending_passkey_expires" in request.session:
|
||||
del request.session["pending_passkey_expires"]
|
||||
return Response(
|
||||
{"detail": "Passkey registration session expired. Please start registration again."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Get stored state from session (no request needed, uses context)
|
||||
state = webauthn_auth.get_state()
|
||||
if not state:
|
||||
@@ -164,24 +191,33 @@ def register_passkey(request):
|
||||
|
||||
# Use the correct allauth API: complete_registration
|
||||
try:
|
||||
from allauth.mfa.models import Authenticator
|
||||
from allauth.mfa.webauthn.internal.auth import WebAuthn
|
||||
|
||||
# Parse the credential response
|
||||
# Parse the credential response to validate it
|
||||
credential_data = webauthn_auth.parse_registration_response(credential)
|
||||
|
||||
# Complete registration - returns AuthenticatorData (binding)
|
||||
authenticator_data = webauthn_auth.complete_registration(credential_data)
|
||||
# Complete registration to validate and clear state
|
||||
webauthn_auth.complete_registration(credential_data)
|
||||
|
||||
# Create the Authenticator record ourselves
|
||||
authenticator = Authenticator.objects.create(
|
||||
user=request.user,
|
||||
type=Authenticator.Type.WEBAUTHN,
|
||||
data={
|
||||
"name": name,
|
||||
"credential": authenticator_data.credential_data.aaguid.hex if authenticator_data.credential_data else None,
|
||||
},
|
||||
# Use allauth's WebAuthn.add() to create the Authenticator properly
|
||||
# It stores the raw credential dict and name in the data field
|
||||
webauthn_wrapper = WebAuthn.add(
|
||||
request.user,
|
||||
name,
|
||||
credential, # Pass raw credential dict, not parsed data
|
||||
)
|
||||
# State is cleared internally by complete_registration
|
||||
authenticator = webauthn_wrapper.instance
|
||||
|
||||
# Log security event
|
||||
log_security_event(
|
||||
"passkey_registered",
|
||||
request,
|
||||
user=request.user,
|
||||
metadata={"passkey_name": name, "passkey_id": str(authenticator.id) if authenticator else None},
|
||||
)
|
||||
|
||||
# Send security notification email
|
||||
send_security_notification(request.user, "passkey_registered", {"passkey_name": name})
|
||||
|
||||
return Response({
|
||||
"detail": "Passkey registered successfully",
|
||||
@@ -345,6 +381,12 @@ def delete_passkey(request, passkey_id):
|
||||
try:
|
||||
from allauth.mfa.models import Authenticator
|
||||
|
||||
from apps.accounts.services.security_service import (
|
||||
check_auth_method_availability,
|
||||
log_security_event,
|
||||
send_security_notification,
|
||||
)
|
||||
|
||||
user = request.user
|
||||
password = request.data.get("password", "")
|
||||
|
||||
@@ -355,6 +397,17 @@ def delete_passkey(request, passkey_id):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Check if user has other auth methods before removing passkey
|
||||
auth_methods = check_auth_method_availability(user)
|
||||
|
||||
# If this is the last passkey and user has no other auth method, block removal
|
||||
if auth_methods["passkey_count"] == 1:
|
||||
if not auth_methods["has_password"] and not auth_methods["has_social"] and not auth_methods["has_totp"]:
|
||||
return Response(
|
||||
{"detail": "Cannot remove last passkey: you must have at least one authentication method. Please set a password or connect a social account first."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Find and delete the passkey
|
||||
try:
|
||||
authenticator = Authenticator.objects.get(
|
||||
@@ -362,7 +415,20 @@ def delete_passkey(request, passkey_id):
|
||||
user=user,
|
||||
type=Authenticator.Type.WEBAUTHN,
|
||||
)
|
||||
passkey_name = authenticator.data.get("name", "Passkey") if authenticator.data else "Passkey"
|
||||
authenticator.delete()
|
||||
|
||||
# Log security event
|
||||
log_security_event(
|
||||
"passkey_removed",
|
||||
request,
|
||||
user=user,
|
||||
metadata={"passkey_name": passkey_name, "passkey_id": str(passkey_id)},
|
||||
)
|
||||
|
||||
# Send security notification email
|
||||
send_security_notification(user, "passkey_removed", {"passkey_name": passkey_name})
|
||||
|
||||
except Authenticator.DoesNotExist:
|
||||
return Response(
|
||||
{"detail": "Passkey not found"},
|
||||
@@ -466,6 +532,7 @@ def rename_passkey(request, passkey_id):
|
||||
tags=["Passkey"],
|
||||
)
|
||||
@api_view(["POST"])
|
||||
@permission_classes([AllowAny])
|
||||
def get_login_passkey_options(request):
|
||||
"""Get WebAuthn authentication options for MFA login flow (unauthenticated)."""
|
||||
from django.core.cache import cache
|
||||
|
||||
@@ -29,6 +29,7 @@ from .views import (
|
||||
PasswordResetAPIView,
|
||||
ProcessOAuthProfileAPIView,
|
||||
ResendVerificationAPIView,
|
||||
SessionToTokenAPIView, # For passkey login token exchange
|
||||
SignupAPIView,
|
||||
SocialAuthStatusAPIView,
|
||||
SocialProvidersAPIView,
|
||||
@@ -43,6 +44,7 @@ urlpatterns = [
|
||||
path("user/", CurrentUserAPIView.as_view(), name="auth-current-user"),
|
||||
# JWT token management
|
||||
path("token/refresh/", TokenRefreshView.as_view(), name="auth-token-refresh"),
|
||||
path("token/session/", SessionToTokenAPIView.as_view(), name="auth-token-session"), # Exchange session for JWT
|
||||
# Note: dj_rest_auth removed - using custom social auth views below
|
||||
path(
|
||||
"password/reset/",
|
||||
@@ -128,6 +130,7 @@ urlpatterns = [
|
||||
path("sessions/", account_views.list_sessions, name="auth-sessions-list"),
|
||||
path("sessions/<str:session_id>/", account_views.revoke_session, name="auth-session-revoke"),
|
||||
path("password/change/", account_views.change_password, name="auth-password-change-v2"),
|
||||
path("security-log/", account_views.get_security_log, name="auth-security-log"),
|
||||
]
|
||||
|
||||
# Note: User profiles and top lists functionality is now handled by the accounts app
|
||||
|
||||
@@ -212,16 +212,29 @@ class LoginAPIView(APIView):
|
||||
# pass a real HttpRequest to Django login with backend specified
|
||||
login(_get_underlying_request(request), user, backend="django.contrib.auth.backends.ModelBackend")
|
||||
|
||||
# Generate JWT tokens
|
||||
from rest_framework_simplejwt.tokens import RefreshToken
|
||||
# Generate JWT tokens with auth method claims
|
||||
from .jwt import create_tokens_for_user
|
||||
|
||||
refresh = RefreshToken.for_user(user)
|
||||
access_token = refresh.access_token
|
||||
tokens = create_tokens_for_user(
|
||||
user,
|
||||
auth_method="password",
|
||||
mfa_verified=False,
|
||||
provider_mfa=False,
|
||||
)
|
||||
|
||||
# Log successful login
|
||||
from apps.accounts.services.security_service import log_security_event
|
||||
log_security_event(
|
||||
"login_success",
|
||||
request,
|
||||
user=user,
|
||||
metadata={"auth_method": "password", "mfa_required": False},
|
||||
)
|
||||
|
||||
response_serializer = LoginOutputSerializer(
|
||||
{
|
||||
"access": str(access_token),
|
||||
"refresh": str(refresh),
|
||||
"access": tokens["access"],
|
||||
"refresh": tokens["refresh"],
|
||||
"user": user,
|
||||
"message": "Login successful",
|
||||
}
|
||||
@@ -237,6 +250,14 @@ class LoginAPIView(APIView):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
else:
|
||||
# Log failed login attempt
|
||||
from apps.accounts.services.security_service import log_security_event
|
||||
log_security_event(
|
||||
"login_failed",
|
||||
request,
|
||||
user=None,
|
||||
metadata={"username_attempted": email_or_username},
|
||||
)
|
||||
return Response(
|
||||
{"detail": "Invalid credentials"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
@@ -331,8 +352,17 @@ class MFALoginVerifyAPIView(APIView):
|
||||
)
|
||||
|
||||
# Verify MFA - either TOTP or Passkey
|
||||
from apps.accounts.services.security_service import log_security_event
|
||||
|
||||
if totp_code:
|
||||
if not self._verify_totp(user, totp_code):
|
||||
# Log failed MFA attempt
|
||||
log_security_event(
|
||||
"mfa_challenge_failed",
|
||||
request,
|
||||
user=user,
|
||||
metadata={"method": "totp"},
|
||||
)
|
||||
return Response(
|
||||
{"detail": "Invalid verification code"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
@@ -341,6 +371,13 @@ class MFALoginVerifyAPIView(APIView):
|
||||
# Verify passkey/WebAuthn credential
|
||||
passkey_result = self._verify_passkey(request, user, credential)
|
||||
if not passkey_result["success"]:
|
||||
# Log failed MFA attempt
|
||||
log_security_event(
|
||||
"mfa_challenge_failed",
|
||||
request,
|
||||
user=user,
|
||||
metadata={"method": "passkey", "error": passkey_result.get("error")},
|
||||
)
|
||||
return Response(
|
||||
{"detail": passkey_result.get("error", "Passkey verification failed")},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
@@ -357,16 +394,41 @@ class MFALoginVerifyAPIView(APIView):
|
||||
# Complete login
|
||||
login(_get_underlying_request(request), user, backend="django.contrib.auth.backends.ModelBackend")
|
||||
|
||||
# Generate JWT tokens
|
||||
from rest_framework_simplejwt.tokens import RefreshToken
|
||||
# Determine auth method based on what was verified
|
||||
from .jwt import create_tokens_for_user
|
||||
|
||||
refresh = RefreshToken.for_user(user)
|
||||
access_token = refresh.access_token
|
||||
if credential:
|
||||
# Passkey verification - inherently MFA
|
||||
auth_method = "passkey"
|
||||
else:
|
||||
# TOTP verification
|
||||
auth_method = "totp"
|
||||
|
||||
# Log successful MFA challenge and login
|
||||
log_security_event(
|
||||
"mfa_challenge_success",
|
||||
request,
|
||||
user=user,
|
||||
metadata={"method": auth_method},
|
||||
)
|
||||
log_security_event(
|
||||
"login_success",
|
||||
request,
|
||||
user=user,
|
||||
metadata={"auth_method": auth_method, "mfa_verified": True},
|
||||
)
|
||||
|
||||
tokens = create_tokens_for_user(
|
||||
user,
|
||||
auth_method=auth_method,
|
||||
mfa_verified=True,
|
||||
provider_mfa=False,
|
||||
)
|
||||
|
||||
response_serializer = LoginOutputSerializer(
|
||||
{
|
||||
"access": str(access_token),
|
||||
"refresh": str(refresh),
|
||||
"access": tokens["access"],
|
||||
"refresh": tokens["refresh"],
|
||||
"user": user,
|
||||
"message": "Login successful",
|
||||
}
|
||||
@@ -449,6 +511,99 @@ class MFALoginVerifyAPIView(APIView):
|
||||
return {"success": False, "error": "Passkey verification failed"}
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
post=extend_schema(
|
||||
summary="Exchange session for JWT tokens",
|
||||
description="Exchange allauth session_token (from passkey login) for JWT tokens.",
|
||||
responses={
|
||||
200: LoginOutputSerializer,
|
||||
401: "Not authenticated",
|
||||
},
|
||||
tags=["Authentication"],
|
||||
),
|
||||
)
|
||||
class SessionToTokenAPIView(APIView):
|
||||
"""
|
||||
API endpoint to exchange allauth session_token for JWT tokens.
|
||||
|
||||
Used after allauth headless passkey login to get JWT tokens for the frontend.
|
||||
The allauth passkey login returns a session_token, and this endpoint
|
||||
validates it and exchanges it for JWT tokens.
|
||||
"""
|
||||
|
||||
# Allow unauthenticated - we validate the allauth session_token ourselves
|
||||
permission_classes = [AllowAny]
|
||||
authentication_classes = []
|
||||
|
||||
def post(self, request: Request) -> Response:
|
||||
# Get the allauth session_token from header or body
|
||||
session_token = request.headers.get('X-Session-Token') or request.data.get('session_token')
|
||||
|
||||
if not session_token:
|
||||
return Response(
|
||||
{"detail": "Session token required. Provide X-Session-Token header or session_token in body."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Validate the session_token with allauth's session store
|
||||
try:
|
||||
from allauth.headless.tokens.strategies.sessions import SessionTokenStrategy
|
||||
|
||||
strategy = SessionTokenStrategy()
|
||||
session_data = strategy.lookup_session(session_token)
|
||||
|
||||
if not session_data:
|
||||
return Response(
|
||||
{"detail": "Invalid or expired session token."},
|
||||
status=status.HTTP_401_UNAUTHORIZED,
|
||||
)
|
||||
|
||||
# Get user from the session
|
||||
user_id = session_data.get('_auth_user_id')
|
||||
if not user_id:
|
||||
return Response(
|
||||
{"detail": "No user found in session."},
|
||||
status=status.HTTP_401_UNAUTHORIZED,
|
||||
)
|
||||
|
||||
user = UserModel.objects.get(pk=user_id)
|
||||
|
||||
except (ImportError, Exception) as e:
|
||||
logger.error(f"Failed to validate allauth session token: {e}")
|
||||
return Response(
|
||||
{"detail": "Failed to validate session token."},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
# Generate JWT tokens with passkey auth method
|
||||
from .jwt import create_tokens_for_user
|
||||
|
||||
tokens = create_tokens_for_user(
|
||||
user,
|
||||
auth_method="passkey",
|
||||
mfa_verified=True, # Passkey is considered MFA
|
||||
provider_mfa=False,
|
||||
)
|
||||
|
||||
# Log successful session-to-token exchange
|
||||
from apps.accounts.services.security_service import log_security_event
|
||||
log_security_event(
|
||||
"session_to_token",
|
||||
request,
|
||||
user=user,
|
||||
metadata={"auth_method": "passkey"},
|
||||
)
|
||||
|
||||
response_serializer = LoginOutputSerializer(
|
||||
{
|
||||
"access": tokens["access"],
|
||||
"refresh": tokens["refresh"],
|
||||
"user": user,
|
||||
"message": "Token exchange successful",
|
||||
}
|
||||
)
|
||||
return Response(response_serializer.data)
|
||||
|
||||
@extend_schema_view(
|
||||
post=extend_schema(
|
||||
summary="User registration",
|
||||
@@ -516,6 +671,8 @@ class LogoutAPIView(APIView):
|
||||
|
||||
def post(self, request: Request) -> Response:
|
||||
try:
|
||||
user = request.user
|
||||
|
||||
# Get refresh token from request data with proper type handling
|
||||
refresh_token = None
|
||||
if hasattr(request, "data") and request.data is not None:
|
||||
@@ -539,6 +696,15 @@ class LogoutAPIView(APIView):
|
||||
if hasattr(request.user, "auth_token"):
|
||||
request.user.auth_token.delete()
|
||||
|
||||
# Log security event
|
||||
from apps.accounts.services.security_service import log_security_event
|
||||
log_security_event(
|
||||
"logout",
|
||||
request,
|
||||
user=user,
|
||||
metadata={},
|
||||
)
|
||||
|
||||
# Logout from session using the underlying HttpRequest
|
||||
logout(_get_underlying_request(request))
|
||||
|
||||
@@ -804,6 +970,11 @@ class ConnectProviderAPIView(APIView):
|
||||
serializer_class = ConnectProviderInputSerializer
|
||||
|
||||
def post(self, request: Request, provider: str) -> Response:
|
||||
from apps.accounts.services.security_service import (
|
||||
log_security_event,
|
||||
send_security_notification,
|
||||
)
|
||||
|
||||
# Validate provider
|
||||
if provider not in ["google", "discord"]:
|
||||
return Response(
|
||||
@@ -815,6 +986,30 @@ class ConnectProviderAPIView(APIView):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Check if user's email is verified before allowing social account linking
|
||||
# This prevents attackers from linking a social account to an unverified email
|
||||
user = request.user
|
||||
|
||||
# Check allauth email verification status
|
||||
try:
|
||||
from allauth.account.models import EmailAddress
|
||||
primary_email = EmailAddress.objects.filter(user=user, primary=True).first()
|
||||
if primary_email and not primary_email.verified:
|
||||
return Response(
|
||||
{
|
||||
"detail": "Please verify your email address before connecting social accounts",
|
||||
"code": "EMAIL_NOT_VERIFIED",
|
||||
"suggestions": [
|
||||
"Check your email for a verification link",
|
||||
"Request a new verification email from your account settings",
|
||||
],
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
except ImportError:
|
||||
# If allauth.account is not available, skip check
|
||||
pass
|
||||
|
||||
serializer = ConnectProviderInputSerializer(data=request.data)
|
||||
if not serializer.is_valid():
|
||||
return Response(
|
||||
@@ -833,6 +1028,17 @@ class ConnectProviderAPIView(APIView):
|
||||
service = SocialProviderService()
|
||||
result = service.connect_provider(request.user, provider, access_token)
|
||||
|
||||
# Log security event
|
||||
log_security_event(
|
||||
"social_linked",
|
||||
request,
|
||||
user=request.user,
|
||||
metadata={"provider": provider},
|
||||
)
|
||||
|
||||
# Send security notification
|
||||
send_security_notification(request.user, "social_linked", {"provider": provider.title()})
|
||||
|
||||
response_serializer = ConnectProviderOutputSerializer(result)
|
||||
return Response(response_serializer.data)
|
||||
|
||||
@@ -882,6 +1088,11 @@ class DisconnectProviderAPIView(APIView):
|
||||
)
|
||||
|
||||
try:
|
||||
from apps.accounts.services.security_service import (
|
||||
log_security_event,
|
||||
send_security_notification,
|
||||
)
|
||||
|
||||
service = SocialProviderService()
|
||||
|
||||
# Check if disconnection is safe
|
||||
@@ -903,6 +1114,17 @@ class DisconnectProviderAPIView(APIView):
|
||||
# Perform disconnection
|
||||
result = service.disconnect_provider(request.user, provider)
|
||||
|
||||
# Log security event
|
||||
log_security_event(
|
||||
"social_unlinked",
|
||||
request,
|
||||
user=request.user,
|
||||
metadata={"provider": provider},
|
||||
)
|
||||
|
||||
# Send security notification
|
||||
send_security_notification(request.user, "social_unlinked", {"provider": provider.title()})
|
||||
|
||||
response_serializer = DisconnectProviderOutputSerializer(result)
|
||||
return Response(response_serializer.data)
|
||||
|
||||
|
||||
@@ -1044,3 +1044,29 @@ class RideSerializer(serializers.ModelSerializer):
|
||||
"opening_date",
|
||||
"closing_date",
|
||||
]
|
||||
|
||||
|
||||
class RideSubTypeSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for ride sub-types lookup table.
|
||||
|
||||
This serves the /rides/sub-types/ endpoint which the frontend
|
||||
uses to populate sub-type dropdowns filtered by category.
|
||||
"""
|
||||
|
||||
created_by = serializers.CharField(source="created_by.username", read_only=True, allow_null=True)
|
||||
|
||||
class Meta:
|
||||
# Import here to avoid circular imports
|
||||
from apps.rides.models import RideSubType
|
||||
model = RideSubType
|
||||
fields = [
|
||||
"id",
|
||||
"name",
|
||||
"category",
|
||||
"description",
|
||||
"created_by",
|
||||
"created_at",
|
||||
]
|
||||
read_only_fields = ["id", "created_at", "created_by"]
|
||||
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ from .views import (
|
||||
RideListCreateAPIView,
|
||||
RideModelSearchAPIView,
|
||||
RideSearchSuggestionsAPIView,
|
||||
RideSubTypeListAPIView,
|
||||
)
|
||||
|
||||
# Create router for nested photo endpoints
|
||||
@@ -63,6 +64,8 @@ urlpatterns = [
|
||||
# Manufacturer and Designer endpoints
|
||||
path("manufacturers/", ManufacturerListAPIView.as_view(), name="manufacturer-list"),
|
||||
path("designers/", DesignerListAPIView.as_view(), name="designer-list"),
|
||||
# Ride sub-types endpoint - for autocomplete dropdowns
|
||||
path("sub-types/", RideSubTypeListAPIView.as_view(), name="ride-sub-type-list"),
|
||||
# Ride model management endpoints - nested under rides/manufacturers
|
||||
path(
|
||||
"manufacturers/<slug:manufacturer_slug>/",
|
||||
|
||||
@@ -2422,3 +2422,53 @@ class ManufacturerListAPIView(BaseCompanyListAPIView):
|
||||
)
|
||||
class DesignerListAPIView(BaseCompanyListAPIView):
|
||||
role = "DESIGNER"
|
||||
|
||||
|
||||
# === RIDE SUB-TYPES ===
|
||||
|
||||
|
||||
@extend_schema(
|
||||
summary="List ride sub-types",
|
||||
description="List ride sub-types, optionally filtered by category. Used for autocomplete dropdowns.",
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
"category",
|
||||
OpenApiTypes.STR,
|
||||
description="Filter by ride category (e.g., 'RC' for roller coaster)",
|
||||
),
|
||||
],
|
||||
responses={200: OpenApiTypes.OBJECT},
|
||||
tags=["Rides"],
|
||||
)
|
||||
class RideSubTypeListAPIView(APIView):
|
||||
"""
|
||||
API View for listing ride sub-types.
|
||||
|
||||
Used by the frontend's useRideSubTypes hook to populate
|
||||
sub-type dropdown menus filtered by ride category.
|
||||
|
||||
Caching: 30-minute timeout (1800s) - sub-types are stable lookup data.
|
||||
"""
|
||||
|
||||
permission_classes = [permissions.AllowAny]
|
||||
|
||||
@cache_api_response(timeout=1800, key_prefix="ride_sub_types")
|
||||
def get(self, request: Request) -> Response:
|
||||
from apps.rides.models import RideSubType
|
||||
from apps.api.v1.rides.serializers import RideSubTypeSerializer
|
||||
|
||||
# Start with all sub-types
|
||||
queryset = RideSubType.objects.all().order_by("name")
|
||||
|
||||
# Apply category filter if provided
|
||||
category = request.query_params.get("category")
|
||||
if category:
|
||||
queryset = queryset.filter(category=category)
|
||||
|
||||
# Serialize and return
|
||||
serializer = RideSubTypeSerializer(queryset, many=True)
|
||||
return Response({
|
||||
"results": serializer.data,
|
||||
"count": queryset.count(),
|
||||
})
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ from drf_spectacular.utils import (
|
||||
)
|
||||
from rest_framework import serializers
|
||||
|
||||
from apps.core.choices.serializers import RichChoiceFieldSerializer
|
||||
from apps.core.choices.serializers import RichChoiceFieldSerializer, RichChoiceSerializerField
|
||||
|
||||
from .shared import ModelChoices
|
||||
|
||||
@@ -87,30 +87,38 @@ class CompanyCreateInputSerializer(serializers.Serializer):
|
||||
description = serializers.CharField(allow_blank=True, default="")
|
||||
website = serializers.URLField(required=False, allow_blank=True)
|
||||
|
||||
# Entity type and status
|
||||
person_type = serializers.ChoiceField(
|
||||
choices=["INDIVIDUAL", "FIRM", "ORGANIZATION", "CORPORATION", "PARTNERSHIP", "GOVERNMENT"],
|
||||
# Entity type and status - using RichChoiceSerializerField
|
||||
person_type = RichChoiceSerializerField(
|
||||
choice_group="person_types",
|
||||
domain="parks",
|
||||
required=False,
|
||||
allow_blank=True,
|
||||
)
|
||||
status = serializers.ChoiceField(
|
||||
choices=["ACTIVE", "DEFUNCT", "MERGED", "ACQUIRED", "RENAMED", "DORMANT"],
|
||||
status = RichChoiceSerializerField(
|
||||
choice_group="company_statuses",
|
||||
domain="parks",
|
||||
default="ACTIVE",
|
||||
)
|
||||
|
||||
# Founding information
|
||||
founded_year = serializers.IntegerField(required=False, allow_null=True)
|
||||
founded_date = serializers.DateField(required=False, allow_null=True)
|
||||
founded_date_precision = serializers.ChoiceField(
|
||||
choices=["YEAR", "MONTH", "DAY"],
|
||||
founded_date_precision = RichChoiceSerializerField(
|
||||
choice_group="date_precision",
|
||||
domain="parks",
|
||||
required=False,
|
||||
allow_blank=True,
|
||||
)
|
||||
|
||||
# Image URLs
|
||||
# Image URLs (legacy - prefer using image IDs)
|
||||
logo_url = serializers.URLField(required=False, allow_blank=True)
|
||||
banner_image_url = serializers.URLField(required=False, allow_blank=True)
|
||||
card_image_url = serializers.URLField(required=False, allow_blank=True)
|
||||
|
||||
# Cloudflare image IDs (preferred for new submissions)
|
||||
logo_image_id = serializers.CharField(max_length=255, required=False, allow_blank=True)
|
||||
banner_image_id = serializers.CharField(max_length=255, required=False, allow_blank=True)
|
||||
card_image_id = serializers.CharField(max_length=255, required=False, allow_blank=True)
|
||||
|
||||
|
||||
class CompanyUpdateInputSerializer(serializers.Serializer):
|
||||
@@ -124,30 +132,38 @@ class CompanyUpdateInputSerializer(serializers.Serializer):
|
||||
description = serializers.CharField(allow_blank=True, required=False)
|
||||
website = serializers.URLField(required=False, allow_blank=True)
|
||||
|
||||
# Entity type and status
|
||||
person_type = serializers.ChoiceField(
|
||||
choices=["INDIVIDUAL", "FIRM", "ORGANIZATION", "CORPORATION", "PARTNERSHIP", "GOVERNMENT"],
|
||||
# Entity type and status - using RichChoiceSerializerField
|
||||
person_type = RichChoiceSerializerField(
|
||||
choice_group="person_types",
|
||||
domain="parks",
|
||||
required=False,
|
||||
allow_blank=True,
|
||||
)
|
||||
status = serializers.ChoiceField(
|
||||
choices=["ACTIVE", "DEFUNCT", "MERGED", "ACQUIRED", "RENAMED", "DORMANT"],
|
||||
status = RichChoiceSerializerField(
|
||||
choice_group="company_statuses",
|
||||
domain="parks",
|
||||
required=False,
|
||||
)
|
||||
|
||||
# Founding information
|
||||
founded_year = serializers.IntegerField(required=False, allow_null=True)
|
||||
founded_date = serializers.DateField(required=False, allow_null=True)
|
||||
founded_date_precision = serializers.ChoiceField(
|
||||
choices=["YEAR", "MONTH", "DAY"],
|
||||
founded_date_precision = RichChoiceSerializerField(
|
||||
choice_group="date_precision",
|
||||
domain="parks",
|
||||
required=False,
|
||||
allow_blank=True,
|
||||
)
|
||||
|
||||
# Image URLs
|
||||
# Image URLs (legacy - prefer using image IDs)
|
||||
logo_url = serializers.URLField(required=False, allow_blank=True)
|
||||
banner_image_url = serializers.URLField(required=False, allow_blank=True)
|
||||
card_image_url = serializers.URLField(required=False, allow_blank=True)
|
||||
|
||||
# Cloudflare image IDs (preferred for new submissions)
|
||||
logo_image_id = serializers.CharField(max_length=255, required=False, allow_blank=True)
|
||||
banner_image_id = serializers.CharField(max_length=255, required=False, allow_blank=True)
|
||||
card_image_id = serializers.CharField(max_length=255, required=False, allow_blank=True)
|
||||
|
||||
|
||||
# === RIDE MODEL SERIALIZERS ===
|
||||
|
||||
@@ -493,6 +493,18 @@ def ensure_filter_option_format(options: list[Any]) -> list[dict[str, Any]]:
|
||||
"count": option.get("count"),
|
||||
"selected": option.get("selected", False),
|
||||
}
|
||||
elif isinstance(option, tuple):
|
||||
# Tuple format: (value, label) or (value, label, count)
|
||||
if len(option) >= 2:
|
||||
standardized_option = {
|
||||
"value": str(option[0]),
|
||||
"label": str(option[1]),
|
||||
"count": option[2] if len(option) > 2 else None,
|
||||
"selected": False,
|
||||
}
|
||||
else:
|
||||
# Single-element tuple, treat as simple value
|
||||
standardized_option = {"value": str(option[0]), "label": str(option[0]), "count": None, "selected": False}
|
||||
elif hasattr(option, "value") and hasattr(option, "label"):
|
||||
# RichChoice object format
|
||||
standardized_option = {
|
||||
|
||||
@@ -34,6 +34,17 @@ from apps.core.api.analytics_views import (
|
||||
RequestMetadataViewSet,
|
||||
)
|
||||
|
||||
# Import observability views
|
||||
from apps.core.api.observability_views import (
|
||||
AlertCorrelationViewSet,
|
||||
AnomalyViewSet,
|
||||
CleanupJobLogViewSet,
|
||||
DataRetentionStatsView,
|
||||
PipelineErrorViewSet,
|
||||
)
|
||||
from apps.notifications.api.log_views import NotificationLogViewSet
|
||||
from apps.moderation.views import ModerationAuditLogViewSet
|
||||
|
||||
# Create the main API router
|
||||
router = DefaultRouter()
|
||||
|
||||
@@ -44,6 +55,14 @@ router.register(r"rankings", RideRankingViewSet, basename="ranking")
|
||||
router.register(r"request_metadata", RequestMetadataViewSet, basename="request_metadata")
|
||||
router.register(r"approval_transaction_metrics", ApprovalTransactionMetricViewSet, basename="approval_transaction_metrics")
|
||||
|
||||
# Register observability endpoints (Supabase table parity)
|
||||
router.register(r"pipeline_errors", PipelineErrorViewSet, basename="pipeline_errors")
|
||||
router.register(r"notification_logs", NotificationLogViewSet, basename="notification_logs")
|
||||
router.register(r"cleanup_job_log", CleanupJobLogViewSet, basename="cleanup_job_log")
|
||||
router.register(r"moderation_audit_log", ModerationAuditLogViewSet, basename="moderation_audit_log")
|
||||
router.register(r"alert_correlations_view", AlertCorrelationViewSet, basename="alert_correlations_view")
|
||||
router.register(r"recent_anomalies_view", AnomalyViewSet, basename="recent_anomalies_view")
|
||||
|
||||
app_name = "api_v1"
|
||||
|
||||
urlpatterns = [
|
||||
@@ -53,6 +72,8 @@ urlpatterns = [
|
||||
path("auth/", include("apps.api.v1.auth.urls")),
|
||||
# Analytics endpoints (error_summary is a view, not a viewset)
|
||||
path("error_summary/", ErrorSummaryView.as_view(), name="error-summary"),
|
||||
# Data retention stats view (aggregation endpoint)
|
||||
path("data_retention_stats/", DataRetentionStatsView.as_view(), name="data-retention-stats"),
|
||||
# Health check endpoints
|
||||
path("health/", HealthCheckAPIView.as_view(), name="health-check"),
|
||||
path("health/simple/", SimpleHealthAPIView.as_view(), name="simple-health"),
|
||||
|
||||
176
backend/apps/core/api/observability_serializers.py
Normal file
176
backend/apps/core/api/observability_serializers.py
Normal file
@@ -0,0 +1,176 @@
|
||||
"""
|
||||
Serializers for observability API endpoints.
|
||||
|
||||
Provides serializers for PipelineError, Anomaly, AlertCorrelationRule,
|
||||
CleanupJobLog, and DataRetentionStats.
|
||||
"""
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
from apps.core.choices.serializers import RichChoiceSerializerField
|
||||
from apps.core.models import (
|
||||
AlertCorrelationRule,
|
||||
Anomaly,
|
||||
CleanupJobLog,
|
||||
PipelineError,
|
||||
)
|
||||
|
||||
|
||||
class PipelineErrorSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for pipeline errors."""
|
||||
|
||||
severity = RichChoiceSerializerField(
|
||||
choice_group="pipeline_error_severities",
|
||||
domain="core",
|
||||
)
|
||||
resolved_by_username = serializers.CharField(
|
||||
source="resolved_by.username",
|
||||
read_only=True,
|
||||
allow_null=True,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = PipelineError
|
||||
fields = [
|
||||
"id",
|
||||
"function_name",
|
||||
"error_message",
|
||||
"error_code",
|
||||
"error_context",
|
||||
"stack_trace",
|
||||
"severity",
|
||||
"submission_id",
|
||||
"item_id",
|
||||
"request_id",
|
||||
"trace_id",
|
||||
"resolved",
|
||||
"resolved_by",
|
||||
"resolved_by_username",
|
||||
"resolved_at",
|
||||
"resolution_notes",
|
||||
"occurred_at",
|
||||
]
|
||||
read_only_fields = ["id", "occurred_at", "resolved_by_username"]
|
||||
|
||||
|
||||
class PipelineErrorResolveSerializer(serializers.Serializer):
|
||||
"""Serializer for resolving pipeline errors."""
|
||||
|
||||
resolution_notes = serializers.CharField(required=False, allow_blank=True)
|
||||
|
||||
|
||||
class AnomalySerializer(serializers.ModelSerializer):
|
||||
"""Serializer for detected anomalies."""
|
||||
|
||||
anomaly_type = RichChoiceSerializerField(
|
||||
choice_group="anomaly_types",
|
||||
domain="core",
|
||||
)
|
||||
severity = RichChoiceSerializerField(
|
||||
choice_group="severity_levels",
|
||||
domain="core",
|
||||
)
|
||||
alert_message = serializers.CharField(
|
||||
source="alert.message",
|
||||
read_only=True,
|
||||
allow_null=True,
|
||||
)
|
||||
alert_resolved_at = serializers.DateTimeField(
|
||||
source="alert.resolved_at",
|
||||
read_only=True,
|
||||
allow_null=True,
|
||||
)
|
||||
alert_id = serializers.UUIDField(
|
||||
source="alert.id",
|
||||
read_only=True,
|
||||
allow_null=True,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Anomaly
|
||||
fields = [
|
||||
"id",
|
||||
"metric_name",
|
||||
"metric_category",
|
||||
"anomaly_type",
|
||||
"severity",
|
||||
"anomaly_value",
|
||||
"baseline_value",
|
||||
"deviation_score",
|
||||
"confidence_score",
|
||||
"detection_algorithm",
|
||||
"time_window_start",
|
||||
"time_window_end",
|
||||
"alert_created",
|
||||
"alert_id",
|
||||
"alert_message",
|
||||
"alert_resolved_at",
|
||||
"detected_at",
|
||||
]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"detected_at",
|
||||
"alert_id",
|
||||
"alert_message",
|
||||
"alert_resolved_at",
|
||||
]
|
||||
|
||||
|
||||
class AlertCorrelationRuleSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for alert correlation rules."""
|
||||
|
||||
incident_severity = RichChoiceSerializerField(
|
||||
choice_group="severity_levels",
|
||||
domain="core",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = AlertCorrelationRule
|
||||
fields = [
|
||||
"id",
|
||||
"rule_name",
|
||||
"rule_description",
|
||||
"min_alerts_required",
|
||||
"time_window_minutes",
|
||||
"incident_severity",
|
||||
"incident_title_template",
|
||||
"is_active",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
read_only_fields = ["id", "created_at", "updated_at"]
|
||||
|
||||
|
||||
class CleanupJobLogSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for cleanup job logs."""
|
||||
|
||||
status = RichChoiceSerializerField(
|
||||
choice_group="cleanup_job_statuses",
|
||||
domain="core",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = CleanupJobLog
|
||||
fields = [
|
||||
"id",
|
||||
"job_name",
|
||||
"status",
|
||||
"records_processed",
|
||||
"records_deleted",
|
||||
"error_message",
|
||||
"duration_ms",
|
||||
"executed_at",
|
||||
]
|
||||
read_only_fields = ["id", "executed_at"]
|
||||
|
||||
|
||||
class DataRetentionStatsSerializer(serializers.Serializer):
|
||||
"""Serializer for data retention statistics view."""
|
||||
|
||||
table_name = serializers.CharField()
|
||||
total_records = serializers.IntegerField()
|
||||
last_7_days = serializers.IntegerField()
|
||||
last_30_days = serializers.IntegerField()
|
||||
oldest_record = serializers.DateTimeField(allow_null=True)
|
||||
newest_record = serializers.DateTimeField(allow_null=True)
|
||||
table_size = serializers.CharField()
|
||||
351
backend/apps/core/api/observability_views.py
Normal file
351
backend/apps/core/api/observability_views.py
Normal file
@@ -0,0 +1,351 @@
|
||||
"""
|
||||
ViewSets and Views for observability API endpoints.
|
||||
|
||||
Provides CRUD operations for PipelineError, read-only access for
|
||||
Anomaly, AlertCorrelationRule, CleanupJobLog, and aggregated views
|
||||
for DataRetentionStats.
|
||||
"""
|
||||
|
||||
from django.db import connection
|
||||
from django.db.models import Count, Max, Min
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.utils import timezone
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from drf_spectacular.utils import extend_schema, extend_schema_view
|
||||
from rest_framework import status, viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.filters import OrderingFilter, SearchFilter
|
||||
from rest_framework.permissions import IsAdminUser
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from apps.core.models import (
|
||||
AlertCorrelationRule,
|
||||
Anomaly,
|
||||
CleanupJobLog,
|
||||
PipelineError,
|
||||
)
|
||||
|
||||
from .observability_serializers import (
|
||||
AlertCorrelationRuleSerializer,
|
||||
AnomalySerializer,
|
||||
CleanupJobLogSerializer,
|
||||
DataRetentionStatsSerializer,
|
||||
PipelineErrorResolveSerializer,
|
||||
PipelineErrorSerializer,
|
||||
)
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
list=extend_schema(
|
||||
summary="List pipeline errors",
|
||||
description="Get all pipeline errors, optionally filtered by severity or resolved status.",
|
||||
tags=["Admin - Observability"],
|
||||
),
|
||||
retrieve=extend_schema(
|
||||
summary="Get pipeline error",
|
||||
description="Get details of a specific pipeline error.",
|
||||
tags=["Admin - Observability"],
|
||||
),
|
||||
create=extend_schema(
|
||||
summary="Create pipeline error",
|
||||
description="Create a new pipeline error.",
|
||||
tags=["Admin - Observability"],
|
||||
),
|
||||
update=extend_schema(
|
||||
summary="Update pipeline error",
|
||||
description="Update an existing pipeline error.",
|
||||
tags=["Admin - Observability"],
|
||||
),
|
||||
partial_update=extend_schema(
|
||||
summary="Partial update pipeline error",
|
||||
description="Partially update an existing pipeline error.",
|
||||
tags=["Admin - Observability"],
|
||||
),
|
||||
destroy=extend_schema(
|
||||
summary="Delete pipeline error",
|
||||
description="Delete a pipeline error.",
|
||||
tags=["Admin - Observability"],
|
||||
),
|
||||
)
|
||||
class PipelineErrorViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
ViewSet for managing pipeline errors.
|
||||
|
||||
Provides CRUD operations plus a resolve action for marking errors as resolved.
|
||||
"""
|
||||
|
||||
queryset = PipelineError.objects.select_related("resolved_by").all()
|
||||
serializer_class = PipelineErrorSerializer
|
||||
permission_classes = [IsAdminUser]
|
||||
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
|
||||
filterset_fields = ["severity", "function_name", "resolved", "error_code"]
|
||||
search_fields = ["error_message", "function_name", "error_code"]
|
||||
ordering_fields = ["occurred_at", "severity"]
|
||||
ordering = ["-occurred_at"]
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = super().get_queryset()
|
||||
|
||||
# Date range filtering
|
||||
start_date = self.request.query_params.get("start_date")
|
||||
end_date = self.request.query_params.get("end_date")
|
||||
|
||||
if start_date:
|
||||
queryset = queryset.filter(occurred_at__gte=start_date)
|
||||
if end_date:
|
||||
queryset = queryset.filter(occurred_at__lte=end_date)
|
||||
|
||||
return queryset
|
||||
|
||||
@extend_schema(
|
||||
summary="Resolve pipeline error",
|
||||
description="Mark a pipeline error as resolved.",
|
||||
request=PipelineErrorResolveSerializer,
|
||||
responses={200: PipelineErrorSerializer},
|
||||
tags=["Admin - Observability"],
|
||||
)
|
||||
@action(detail=True, methods=["post"])
|
||||
def resolve(self, request, pk=None):
|
||||
"""Mark a pipeline error as resolved."""
|
||||
error = self.get_object()
|
||||
|
||||
if error.resolved:
|
||||
return Response(
|
||||
{"detail": "Error is already resolved"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
serializer = PipelineErrorResolveSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
error.resolved = True
|
||||
error.resolved_at = timezone.now()
|
||||
error.resolved_by = request.user
|
||||
error.resolution_notes = serializer.validated_data.get("resolution_notes", "")
|
||||
error.save()
|
||||
|
||||
return Response(PipelineErrorSerializer(error).data)
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
list=extend_schema(
|
||||
summary="List recent anomalies",
|
||||
description="Get recent anomalies with optional filtering by severity or type.",
|
||||
tags=["Admin - Observability"],
|
||||
),
|
||||
retrieve=extend_schema(
|
||||
summary="Get anomaly details",
|
||||
description="Get details of a specific anomaly.",
|
||||
tags=["Admin - Observability"],
|
||||
),
|
||||
)
|
||||
class AnomalyViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
"""
|
||||
ViewSet for viewing detected anomalies.
|
||||
|
||||
Provides read-only access to anomaly data with filtering options.
|
||||
This serves as the recent_anomalies_view endpoint.
|
||||
"""
|
||||
|
||||
queryset = Anomaly.objects.select_related("alert").all()
|
||||
serializer_class = AnomalySerializer
|
||||
permission_classes = [IsAdminUser]
|
||||
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
|
||||
filterset_fields = ["severity", "anomaly_type", "metric_category", "alert_created"]
|
||||
search_fields = ["metric_name", "metric_category"]
|
||||
ordering_fields = ["detected_at", "severity", "deviation_score"]
|
||||
ordering = ["-detected_at"]
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = super().get_queryset()
|
||||
|
||||
# Date range filtering
|
||||
start_date = self.request.query_params.get("start_date")
|
||||
end_date = self.request.query_params.get("end_date")
|
||||
|
||||
if start_date:
|
||||
queryset = queryset.filter(detected_at__gte=start_date)
|
||||
if end_date:
|
||||
queryset = queryset.filter(detected_at__lte=end_date)
|
||||
|
||||
return queryset
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
list=extend_schema(
|
||||
summary="List alert correlations",
|
||||
description="Get all alert correlation rules with optional filtering.",
|
||||
tags=["Admin - Observability"],
|
||||
),
|
||||
retrieve=extend_schema(
|
||||
summary="Get alert correlation rule",
|
||||
description="Get details of a specific alert correlation rule.",
|
||||
tags=["Admin - Observability"],
|
||||
),
|
||||
create=extend_schema(
|
||||
summary="Create alert correlation rule",
|
||||
description="Create a new alert correlation rule.",
|
||||
tags=["Admin - Observability"],
|
||||
),
|
||||
update=extend_schema(
|
||||
summary="Update alert correlation rule",
|
||||
description="Update an existing alert correlation rule.",
|
||||
tags=["Admin - Observability"],
|
||||
),
|
||||
partial_update=extend_schema(
|
||||
summary="Partial update alert correlation rule",
|
||||
description="Partially update an existing alert correlation rule.",
|
||||
tags=["Admin - Observability"],
|
||||
),
|
||||
destroy=extend_schema(
|
||||
summary="Delete alert correlation rule",
|
||||
description="Delete an alert correlation rule.",
|
||||
tags=["Admin - Observability"],
|
||||
),
|
||||
)
|
||||
class AlertCorrelationViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
ViewSet for managing alert correlation rules.
|
||||
|
||||
Provides CRUD operations for configuring how alerts are correlated.
|
||||
This serves as the alert_correlations_view endpoint.
|
||||
"""
|
||||
|
||||
queryset = AlertCorrelationRule.objects.all()
|
||||
serializer_class = AlertCorrelationRuleSerializer
|
||||
permission_classes = [IsAdminUser]
|
||||
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
|
||||
filterset_fields = ["is_active", "incident_severity"]
|
||||
search_fields = ["rule_name", "rule_description"]
|
||||
ordering_fields = ["rule_name", "created_at"]
|
||||
ordering = ["rule_name"]
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
list=extend_schema(
|
||||
summary="List cleanup job logs",
|
||||
description="Get all cleanup job logs with optional filtering by status.",
|
||||
tags=["Admin - Observability"],
|
||||
),
|
||||
retrieve=extend_schema(
|
||||
summary="Get cleanup job log",
|
||||
description="Get details of a specific cleanup job log entry.",
|
||||
tags=["Admin - Observability"],
|
||||
),
|
||||
)
|
||||
class CleanupJobLogViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
"""
|
||||
ViewSet for viewing cleanup job logs.
|
||||
|
||||
Provides read-only access to cleanup job execution history.
|
||||
"""
|
||||
|
||||
queryset = CleanupJobLog.objects.all()
|
||||
serializer_class = CleanupJobLogSerializer
|
||||
permission_classes = [IsAdminUser]
|
||||
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
|
||||
filterset_fields = ["status", "job_name"]
|
||||
search_fields = ["job_name", "error_message"]
|
||||
ordering_fields = ["executed_at", "duration_ms"]
|
||||
ordering = ["-executed_at"]
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = super().get_queryset()
|
||||
|
||||
# Date range filtering
|
||||
start_date = self.request.query_params.get("start_date")
|
||||
end_date = self.request.query_params.get("end_date")
|
||||
|
||||
if start_date:
|
||||
queryset = queryset.filter(executed_at__gte=start_date)
|
||||
if end_date:
|
||||
queryset = queryset.filter(executed_at__lte=end_date)
|
||||
|
||||
return queryset
|
||||
|
||||
|
||||
@extend_schema(
|
||||
summary="Get data retention stats",
|
||||
description="Get aggregated data retention statistics for monitoring database growth.",
|
||||
tags=["Admin - Observability"],
|
||||
responses={200: DataRetentionStatsSerializer(many=True)},
|
||||
)
|
||||
class DataRetentionStatsView(APIView):
|
||||
"""
|
||||
API view for data retention statistics.
|
||||
|
||||
Returns aggregated statistics about table sizes, record counts,
|
||||
and data age for monitoring data retention and growth.
|
||||
"""
|
||||
|
||||
permission_classes = [IsAdminUser]
|
||||
|
||||
def get(self, request):
|
||||
"""Get data retention statistics for key tables."""
|
||||
from datetime import timedelta
|
||||
|
||||
from django.apps import apps
|
||||
|
||||
now = timezone.now()
|
||||
seven_days_ago = now - timedelta(days=7)
|
||||
thirty_days_ago = now - timedelta(days=30)
|
||||
|
||||
# Tables to report on
|
||||
tables_to_check = [
|
||||
("core", "pipelineerror", "occurred_at"),
|
||||
("core", "applicationerror", "created_at"),
|
||||
("core", "systemalert", "created_at"),
|
||||
("core", "requestmetadata", "created_at"),
|
||||
("core", "anomaly", "detected_at"),
|
||||
("core", "cleanupjoblog", "executed_at"),
|
||||
("moderation", "editsubmission", "created_at"),
|
||||
("moderation", "moderationauditlog", "created_at"),
|
||||
("notifications", "notificationlog", "created_at"),
|
||||
]
|
||||
|
||||
stats = []
|
||||
for app_label, model_name, date_field in tables_to_check:
|
||||
try:
|
||||
model = apps.get_model(app_label, model_name)
|
||||
filter_kwargs_7d = {f"{date_field}__gte": seven_days_ago}
|
||||
filter_kwargs_30d = {f"{date_field}__gte": thirty_days_ago}
|
||||
|
||||
# Get record counts and date ranges
|
||||
qs = model.objects.aggregate(
|
||||
total=Coalesce(Count("id"), 0),
|
||||
last_7_days=Coalesce(Count("id", filter=model.objects.filter(**filter_kwargs_7d).query.where), 0),
|
||||
last_30_days=Coalesce(Count("id", filter=model.objects.filter(**filter_kwargs_30d).query.where), 0),
|
||||
oldest_record=Min(date_field),
|
||||
newest_record=Max(date_field),
|
||||
)
|
||||
|
||||
# Get table size from database
|
||||
table_name = model._meta.db_table
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT pg_size_pretty(pg_total_relation_size(%s))
|
||||
""",
|
||||
[table_name],
|
||||
)
|
||||
result = cursor.fetchone()
|
||||
table_size = result[0] if result else "Unknown"
|
||||
|
||||
stats.append(
|
||||
{
|
||||
"table_name": table_name,
|
||||
"total_records": model.objects.count(),
|
||||
"last_7_days": model.objects.filter(**filter_kwargs_7d).count(),
|
||||
"last_30_days": model.objects.filter(**filter_kwargs_30d).count(),
|
||||
"oldest_record": qs.get("oldest_record"),
|
||||
"newest_record": qs.get("newest_record"),
|
||||
"table_size": table_size,
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
# Skip tables that don't exist or have errors
|
||||
continue
|
||||
|
||||
serializer = DataRetentionStatsSerializer(stats, many=True)
|
||||
return Response(serializer.data)
|
||||
@@ -15,7 +15,7 @@ Key Components:
|
||||
from .base import ChoiceCategory, ChoiceGroup, RichChoice
|
||||
from .fields import RichChoiceField
|
||||
from .registry import ChoiceRegistry, register_choices
|
||||
from .serializers import RichChoiceOptionSerializer, RichChoiceSerializer
|
||||
from .serializers import RichChoiceOptionSerializer, RichChoiceSerializer, RichChoiceSerializerField
|
||||
from .utils import get_choice_display, validate_choice_value
|
||||
|
||||
__all__ = [
|
||||
@@ -26,6 +26,7 @@ __all__ = [
|
||||
"register_choices",
|
||||
"RichChoiceField",
|
||||
"RichChoiceSerializer",
|
||||
"RichChoiceSerializerField",
|
||||
"RichChoiceOptionSerializer",
|
||||
"validate_choice_value",
|
||||
"get_choice_display",
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
Core System Rich Choice Objects
|
||||
|
||||
This module defines all choice objects for core system functionality,
|
||||
including health checks, API statuses, and other system-level choices.
|
||||
including health checks, API statuses, severity levels, alert types,
|
||||
and other system-level choices.
|
||||
"""
|
||||
|
||||
from .base import ChoiceCategory, RichChoice
|
||||
@@ -124,6 +125,584 @@ ENTITY_TYPES = [
|
||||
),
|
||||
]
|
||||
|
||||
# ============================================================================
|
||||
# Severity Levels (used by ApplicationError, SystemAlert, Incident, RequestMetadata)
|
||||
# ============================================================================
|
||||
SEVERITY_LEVELS = [
|
||||
RichChoice(
|
||||
value="critical",
|
||||
label="Critical",
|
||||
description="Critical issue requiring immediate attention",
|
||||
metadata={
|
||||
"color": "red",
|
||||
"icon": "alert-octagon",
|
||||
"css_class": "bg-red-100 text-red-800 border-red-300",
|
||||
"sort_order": 1,
|
||||
"priority": 1,
|
||||
},
|
||||
category=ChoiceCategory.PRIORITY,
|
||||
),
|
||||
RichChoice(
|
||||
value="high",
|
||||
label="High",
|
||||
description="High priority issue",
|
||||
metadata={
|
||||
"color": "orange",
|
||||
"icon": "alert-triangle",
|
||||
"css_class": "bg-orange-100 text-orange-800 border-orange-300",
|
||||
"sort_order": 2,
|
||||
"priority": 2,
|
||||
},
|
||||
category=ChoiceCategory.PRIORITY,
|
||||
),
|
||||
RichChoice(
|
||||
value="medium",
|
||||
label="Medium",
|
||||
description="Medium priority issue",
|
||||
metadata={
|
||||
"color": "yellow",
|
||||
"icon": "info",
|
||||
"css_class": "bg-yellow-100 text-yellow-800 border-yellow-300",
|
||||
"sort_order": 3,
|
||||
"priority": 3,
|
||||
},
|
||||
category=ChoiceCategory.PRIORITY,
|
||||
),
|
||||
RichChoice(
|
||||
value="low",
|
||||
label="Low",
|
||||
description="Low priority issue",
|
||||
metadata={
|
||||
"color": "blue",
|
||||
"icon": "info",
|
||||
"css_class": "bg-blue-100 text-blue-800 border-blue-300",
|
||||
"sort_order": 4,
|
||||
"priority": 4,
|
||||
},
|
||||
category=ChoiceCategory.PRIORITY,
|
||||
),
|
||||
]
|
||||
|
||||
# Extended severity levels including debug/info/warning/error for RequestMetadata
|
||||
REQUEST_SEVERITY_LEVELS = [
|
||||
RichChoice(
|
||||
value="debug",
|
||||
label="Debug",
|
||||
description="Debug-level information",
|
||||
metadata={
|
||||
"color": "gray",
|
||||
"icon": "bug",
|
||||
"css_class": "bg-gray-100 text-gray-800",
|
||||
"sort_order": 1,
|
||||
},
|
||||
category=ChoiceCategory.TECHNICAL,
|
||||
),
|
||||
RichChoice(
|
||||
value="info",
|
||||
label="Info",
|
||||
description="Informational message",
|
||||
metadata={
|
||||
"color": "blue",
|
||||
"icon": "info",
|
||||
"css_class": "bg-blue-100 text-blue-800",
|
||||
"sort_order": 2,
|
||||
},
|
||||
category=ChoiceCategory.TECHNICAL,
|
||||
),
|
||||
RichChoice(
|
||||
value="warning",
|
||||
label="Warning",
|
||||
description="Warning condition",
|
||||
metadata={
|
||||
"color": "yellow",
|
||||
"icon": "alert-triangle",
|
||||
"css_class": "bg-yellow-100 text-yellow-800",
|
||||
"sort_order": 3,
|
||||
},
|
||||
category=ChoiceCategory.TECHNICAL,
|
||||
),
|
||||
RichChoice(
|
||||
value="error",
|
||||
label="Error",
|
||||
description="Error condition",
|
||||
metadata={
|
||||
"color": "red",
|
||||
"icon": "x-circle",
|
||||
"css_class": "bg-red-100 text-red-800",
|
||||
"sort_order": 4,
|
||||
},
|
||||
category=ChoiceCategory.TECHNICAL,
|
||||
),
|
||||
RichChoice(
|
||||
value="critical",
|
||||
label="Critical",
|
||||
description="Critical error requiring immediate attention",
|
||||
metadata={
|
||||
"color": "red",
|
||||
"icon": "alert-octagon",
|
||||
"css_class": "bg-red-200 text-red-900 font-bold",
|
||||
"sort_order": 5,
|
||||
},
|
||||
category=ChoiceCategory.TECHNICAL,
|
||||
),
|
||||
]
|
||||
|
||||
# ============================================================================
|
||||
# Error/Request Sources
|
||||
# ============================================================================
|
||||
ERROR_SOURCES = [
|
||||
RichChoice(
|
||||
value="frontend",
|
||||
label="Frontend",
|
||||
description="Error originated from frontend application",
|
||||
metadata={
|
||||
"color": "purple",
|
||||
"icon": "monitor",
|
||||
"css_class": "bg-purple-100 text-purple-800",
|
||||
"sort_order": 1,
|
||||
},
|
||||
category=ChoiceCategory.TECHNICAL,
|
||||
),
|
||||
RichChoice(
|
||||
value="backend",
|
||||
label="Backend",
|
||||
description="Error originated from backend server",
|
||||
metadata={
|
||||
"color": "blue",
|
||||
"icon": "server",
|
||||
"css_class": "bg-blue-100 text-blue-800",
|
||||
"sort_order": 2,
|
||||
},
|
||||
category=ChoiceCategory.TECHNICAL,
|
||||
),
|
||||
RichChoice(
|
||||
value="api",
|
||||
label="API",
|
||||
description="Error originated from API layer",
|
||||
metadata={
|
||||
"color": "green",
|
||||
"icon": "code",
|
||||
"css_class": "bg-green-100 text-green-800",
|
||||
"sort_order": 3,
|
||||
},
|
||||
category=ChoiceCategory.TECHNICAL,
|
||||
),
|
||||
]
|
||||
|
||||
# ============================================================================
|
||||
# System Alert Types
|
||||
# ============================================================================
|
||||
SYSTEM_ALERT_TYPES = [
|
||||
RichChoice(
|
||||
value="orphaned_images",
|
||||
label="Orphaned Images",
|
||||
description="Images not associated with any entity",
|
||||
metadata={"color": "orange", "icon": "image", "sort_order": 1},
|
||||
category=ChoiceCategory.TECHNICAL,
|
||||
),
|
||||
RichChoice(
|
||||
value="stale_submissions",
|
||||
label="Stale Submissions",
|
||||
description="Submissions pending for too long",
|
||||
metadata={"color": "yellow", "icon": "clock", "sort_order": 2},
|
||||
category=ChoiceCategory.TECHNICAL,
|
||||
),
|
||||
RichChoice(
|
||||
value="circular_dependency",
|
||||
label="Circular Dependency",
|
||||
description="Detected circular reference in data",
|
||||
metadata={"color": "red", "icon": "refresh-cw", "sort_order": 3},
|
||||
category=ChoiceCategory.TECHNICAL,
|
||||
),
|
||||
RichChoice(
|
||||
value="validation_error",
|
||||
label="Validation Error",
|
||||
description="Data validation failure",
|
||||
metadata={"color": "red", "icon": "alert-circle", "sort_order": 4},
|
||||
category=ChoiceCategory.TECHNICAL,
|
||||
),
|
||||
RichChoice(
|
||||
value="ban_attempt",
|
||||
label="Ban Attempt",
|
||||
description="User ban action was triggered",
|
||||
metadata={"color": "red", "icon": "shield-off", "sort_order": 5},
|
||||
category=ChoiceCategory.SECURITY,
|
||||
),
|
||||
RichChoice(
|
||||
value="upload_timeout",
|
||||
label="Upload Timeout",
|
||||
description="File upload exceeded time limit",
|
||||
metadata={"color": "orange", "icon": "upload-cloud", "sort_order": 6},
|
||||
category=ChoiceCategory.TECHNICAL,
|
||||
),
|
||||
RichChoice(
|
||||
value="high_error_rate",
|
||||
label="High Error Rate",
|
||||
description="Elevated error rate detected",
|
||||
metadata={"color": "red", "icon": "trending-up", "sort_order": 7},
|
||||
category=ChoiceCategory.TECHNICAL,
|
||||
),
|
||||
RichChoice(
|
||||
value="database_connection",
|
||||
label="Database Connection",
|
||||
description="Database connectivity issue",
|
||||
metadata={"color": "red", "icon": "database", "sort_order": 8},
|
||||
category=ChoiceCategory.TECHNICAL,
|
||||
),
|
||||
RichChoice(
|
||||
value="memory_usage",
|
||||
label="Memory Usage",
|
||||
description="High memory consumption detected",
|
||||
metadata={"color": "orange", "icon": "cpu", "sort_order": 9},
|
||||
category=ChoiceCategory.TECHNICAL,
|
||||
),
|
||||
RichChoice(
|
||||
value="queue_backup",
|
||||
label="Queue Backup",
|
||||
description="Task queue is backing up",
|
||||
metadata={"color": "yellow", "icon": "layers", "sort_order": 10},
|
||||
category=ChoiceCategory.TECHNICAL,
|
||||
),
|
||||
]
|
||||
|
||||
# ============================================================================
|
||||
# Metric Types for Rate Limiting
|
||||
# ============================================================================
|
||||
METRIC_TYPES = [
|
||||
RichChoice(
|
||||
value="block_rate",
|
||||
label="Block Rate",
|
||||
description="Percentage of requests being blocked",
|
||||
metadata={"color": "red", "icon": "shield", "sort_order": 1},
|
||||
category=ChoiceCategory.TECHNICAL,
|
||||
),
|
||||
RichChoice(
|
||||
value="total_requests",
|
||||
label="Total Requests",
|
||||
description="Total number of requests",
|
||||
metadata={"color": "blue", "icon": "activity", "sort_order": 2},
|
||||
category=ChoiceCategory.TECHNICAL,
|
||||
),
|
||||
RichChoice(
|
||||
value="unique_ips",
|
||||
label="Unique IPs",
|
||||
description="Number of unique IP addresses",
|
||||
metadata={"color": "purple", "icon": "globe", "sort_order": 3},
|
||||
category=ChoiceCategory.TECHNICAL,
|
||||
),
|
||||
RichChoice(
|
||||
value="function_specific",
|
||||
label="Function Specific",
|
||||
description="Metrics for a specific function",
|
||||
metadata={"color": "green", "icon": "code", "sort_order": 4},
|
||||
category=ChoiceCategory.TECHNICAL,
|
||||
),
|
||||
]
|
||||
|
||||
# ============================================================================
|
||||
# Incident Statuses
|
||||
# ============================================================================
|
||||
INCIDENT_STATUSES = [
|
||||
RichChoice(
|
||||
value="open",
|
||||
label="Open",
|
||||
description="Incident is open and awaiting investigation",
|
||||
metadata={
|
||||
"color": "red",
|
||||
"icon": "alert-circle",
|
||||
"css_class": "bg-red-100 text-red-800",
|
||||
"sort_order": 1,
|
||||
"is_active": True,
|
||||
},
|
||||
category=ChoiceCategory.STATUS,
|
||||
),
|
||||
RichChoice(
|
||||
value="investigating",
|
||||
label="Investigating",
|
||||
description="Incident is being actively investigated",
|
||||
metadata={
|
||||
"color": "yellow",
|
||||
"icon": "search",
|
||||
"css_class": "bg-yellow-100 text-yellow-800",
|
||||
"sort_order": 2,
|
||||
"is_active": True,
|
||||
},
|
||||
category=ChoiceCategory.STATUS,
|
||||
),
|
||||
RichChoice(
|
||||
value="resolved",
|
||||
label="Resolved",
|
||||
description="Incident has been resolved",
|
||||
metadata={
|
||||
"color": "green",
|
||||
"icon": "check-circle",
|
||||
"css_class": "bg-green-100 text-green-800",
|
||||
"sort_order": 3,
|
||||
"is_active": False,
|
||||
},
|
||||
category=ChoiceCategory.STATUS,
|
||||
),
|
||||
RichChoice(
|
||||
value="closed",
|
||||
label="Closed",
|
||||
description="Incident is closed",
|
||||
metadata={
|
||||
"color": "gray",
|
||||
"icon": "x-circle",
|
||||
"css_class": "bg-gray-100 text-gray-800",
|
||||
"sort_order": 4,
|
||||
"is_active": False,
|
||||
},
|
||||
category=ChoiceCategory.STATUS,
|
||||
),
|
||||
]
|
||||
|
||||
# ============================================================================
|
||||
# Alert Sources
|
||||
# ============================================================================
|
||||
ALERT_SOURCES = [
|
||||
RichChoice(
|
||||
value="system",
|
||||
label="System Alert",
|
||||
description="Alert from system monitoring",
|
||||
metadata={"color": "blue", "icon": "server", "sort_order": 1},
|
||||
category=ChoiceCategory.TECHNICAL,
|
||||
),
|
||||
RichChoice(
|
||||
value="rate_limit",
|
||||
label="Rate Limit Alert",
|
||||
description="Alert from rate limiting system",
|
||||
metadata={"color": "orange", "icon": "shield", "sort_order": 2},
|
||||
category=ChoiceCategory.TECHNICAL,
|
||||
),
|
||||
]
|
||||
|
||||
# ============================================================================
|
||||
# Pipeline Error Severities
|
||||
# ============================================================================
|
||||
PIPELINE_ERROR_SEVERITIES = [
|
||||
RichChoice(
|
||||
value="critical",
|
||||
label="Critical",
|
||||
description="Critical pipeline failure requiring immediate attention",
|
||||
metadata={
|
||||
"color": "red",
|
||||
"icon": "alert-octagon",
|
||||
"css_class": "bg-red-100 text-red-800 border-red-300",
|
||||
"sort_order": 1,
|
||||
"priority": 1,
|
||||
},
|
||||
category=ChoiceCategory.PRIORITY,
|
||||
),
|
||||
RichChoice(
|
||||
value="error",
|
||||
label="Error",
|
||||
description="Pipeline error that needs investigation",
|
||||
metadata={
|
||||
"color": "orange",
|
||||
"icon": "alert-triangle",
|
||||
"css_class": "bg-orange-100 text-orange-800 border-orange-300",
|
||||
"sort_order": 2,
|
||||
"priority": 2,
|
||||
},
|
||||
category=ChoiceCategory.PRIORITY,
|
||||
),
|
||||
RichChoice(
|
||||
value="warning",
|
||||
label="Warning",
|
||||
description="Pipeline warning that may need attention",
|
||||
metadata={
|
||||
"color": "yellow",
|
||||
"icon": "alert-circle",
|
||||
"css_class": "bg-yellow-100 text-yellow-800 border-yellow-300",
|
||||
"sort_order": 3,
|
||||
"priority": 3,
|
||||
},
|
||||
category=ChoiceCategory.PRIORITY,
|
||||
),
|
||||
RichChoice(
|
||||
value="info",
|
||||
label="Info",
|
||||
description="Informational pipeline event",
|
||||
metadata={
|
||||
"color": "blue",
|
||||
"icon": "info",
|
||||
"css_class": "bg-blue-100 text-blue-800 border-blue-300",
|
||||
"sort_order": 4,
|
||||
"priority": 4,
|
||||
},
|
||||
category=ChoiceCategory.PRIORITY,
|
||||
),
|
||||
]
|
||||
|
||||
# ============================================================================
|
||||
# Anomaly Types
|
||||
# ============================================================================
|
||||
ANOMALY_TYPES = [
|
||||
RichChoice(
|
||||
value="spike",
|
||||
label="Spike",
|
||||
description="Sudden increase in metric value",
|
||||
metadata={
|
||||
"color": "red",
|
||||
"icon": "trending-up",
|
||||
"css_class": "bg-red-100 text-red-800",
|
||||
"sort_order": 1,
|
||||
},
|
||||
category=ChoiceCategory.TECHNICAL,
|
||||
),
|
||||
RichChoice(
|
||||
value="drop",
|
||||
label="Drop",
|
||||
description="Sudden decrease in metric value",
|
||||
metadata={
|
||||
"color": "blue",
|
||||
"icon": "trending-down",
|
||||
"css_class": "bg-blue-100 text-blue-800",
|
||||
"sort_order": 2,
|
||||
},
|
||||
category=ChoiceCategory.TECHNICAL,
|
||||
),
|
||||
RichChoice(
|
||||
value="trend_change",
|
||||
label="Trend Change",
|
||||
description="Change in the overall trend direction",
|
||||
metadata={
|
||||
"color": "yellow",
|
||||
"icon": "activity",
|
||||
"css_class": "bg-yellow-100 text-yellow-800",
|
||||
"sort_order": 3,
|
||||
},
|
||||
category=ChoiceCategory.TECHNICAL,
|
||||
),
|
||||
RichChoice(
|
||||
value="outlier",
|
||||
label="Outlier",
|
||||
description="Value outside normal distribution",
|
||||
metadata={
|
||||
"color": "purple",
|
||||
"icon": "git-branch",
|
||||
"css_class": "bg-purple-100 text-purple-800",
|
||||
"sort_order": 4,
|
||||
},
|
||||
category=ChoiceCategory.TECHNICAL,
|
||||
),
|
||||
RichChoice(
|
||||
value="threshold_breach",
|
||||
label="Threshold Breach",
|
||||
description="Value exceeded configured threshold",
|
||||
metadata={
|
||||
"color": "orange",
|
||||
"icon": "alert-triangle",
|
||||
"css_class": "bg-orange-100 text-orange-800",
|
||||
"sort_order": 5,
|
||||
},
|
||||
category=ChoiceCategory.TECHNICAL,
|
||||
),
|
||||
]
|
||||
|
||||
# ============================================================================
|
||||
# Cleanup Job Statuses
|
||||
# ============================================================================
|
||||
CLEANUP_JOB_STATUSES = [
|
||||
RichChoice(
|
||||
value="success",
|
||||
label="Success",
|
||||
description="Cleanup job completed successfully",
|
||||
metadata={
|
||||
"color": "green",
|
||||
"icon": "check-circle",
|
||||
"css_class": "bg-green-100 text-green-800",
|
||||
"sort_order": 1,
|
||||
},
|
||||
category=ChoiceCategory.STATUS,
|
||||
),
|
||||
RichChoice(
|
||||
value="failed",
|
||||
label="Failed",
|
||||
description="Cleanup job failed with errors",
|
||||
metadata={
|
||||
"color": "red",
|
||||
"icon": "x-circle",
|
||||
"css_class": "bg-red-100 text-red-800",
|
||||
"sort_order": 2,
|
||||
},
|
||||
category=ChoiceCategory.STATUS,
|
||||
),
|
||||
RichChoice(
|
||||
value="partial",
|
||||
label="Partial",
|
||||
description="Cleanup job completed with some failures",
|
||||
metadata={
|
||||
"color": "yellow",
|
||||
"icon": "alert-circle",
|
||||
"css_class": "bg-yellow-100 text-yellow-800",
|
||||
"sort_order": 3,
|
||||
},
|
||||
category=ChoiceCategory.STATUS,
|
||||
),
|
||||
RichChoice(
|
||||
value="skipped",
|
||||
label="Skipped",
|
||||
description="Cleanup job was skipped",
|
||||
metadata={
|
||||
"color": "gray",
|
||||
"icon": "skip-forward",
|
||||
"css_class": "bg-gray-100 text-gray-800",
|
||||
"sort_order": 4,
|
||||
},
|
||||
category=ChoiceCategory.STATUS,
|
||||
),
|
||||
]
|
||||
|
||||
# ============================================================================
|
||||
# Date Precision (shared across multiple domains)
|
||||
# ============================================================================
|
||||
DATE_PRECISION = [
|
||||
RichChoice(
|
||||
value="exact",
|
||||
label="Exact Date",
|
||||
description="Date is known exactly",
|
||||
metadata={"color": "green", "icon": "calendar", "sort_order": 1, "format": "YYYY-MM-DD"},
|
||||
category=ChoiceCategory.CLASSIFICATION,
|
||||
),
|
||||
RichChoice(
|
||||
value="month",
|
||||
label="Month and Year",
|
||||
description="Only month and year are known",
|
||||
metadata={"color": "blue", "icon": "calendar", "sort_order": 2, "format": "YYYY-MM"},
|
||||
category=ChoiceCategory.CLASSIFICATION,
|
||||
),
|
||||
RichChoice(
|
||||
value="year",
|
||||
label="Year Only",
|
||||
description="Only the year is known",
|
||||
metadata={"color": "yellow", "icon": "calendar", "sort_order": 3, "format": "YYYY"},
|
||||
category=ChoiceCategory.CLASSIFICATION,
|
||||
),
|
||||
RichChoice(
|
||||
value="decade",
|
||||
label="Decade",
|
||||
description="Only the decade is known",
|
||||
metadata={"color": "orange", "icon": "calendar", "sort_order": 4, "format": "YYYYs"},
|
||||
category=ChoiceCategory.CLASSIFICATION,
|
||||
),
|
||||
RichChoice(
|
||||
value="century",
|
||||
label="Century",
|
||||
description="Only the century is known",
|
||||
metadata={"color": "gray", "icon": "calendar", "sort_order": 5, "format": "YYc"},
|
||||
category=ChoiceCategory.CLASSIFICATION,
|
||||
),
|
||||
RichChoice(
|
||||
value="approximate",
|
||||
label="Approximate",
|
||||
description="Date is approximate/estimated",
|
||||
metadata={"color": "gray", "icon": "help-circle", "sort_order": 6, "format": "~YYYY"},
|
||||
category=ChoiceCategory.CLASSIFICATION,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def register_core_choices():
|
||||
"""Register all core system choices with the global registry"""
|
||||
@@ -152,6 +731,95 @@ def register_core_choices():
|
||||
metadata={"domain": "core", "type": "entity_type"},
|
||||
)
|
||||
|
||||
register_choices(
|
||||
name="severity_levels",
|
||||
choices=SEVERITY_LEVELS,
|
||||
domain="core",
|
||||
description="Severity levels for errors and alerts",
|
||||
metadata={"domain": "core", "type": "severity"},
|
||||
)
|
||||
|
||||
register_choices(
|
||||
name="request_severity_levels",
|
||||
choices=REQUEST_SEVERITY_LEVELS,
|
||||
domain="core",
|
||||
description="Extended severity levels for request metadata",
|
||||
metadata={"domain": "core", "type": "request_severity"},
|
||||
)
|
||||
|
||||
register_choices(
|
||||
name="error_sources",
|
||||
choices=ERROR_SOURCES,
|
||||
domain="core",
|
||||
description="Sources of application errors",
|
||||
metadata={"domain": "core", "type": "error_source"},
|
||||
)
|
||||
|
||||
register_choices(
|
||||
name="system_alert_types",
|
||||
choices=SYSTEM_ALERT_TYPES,
|
||||
domain="core",
|
||||
description="Types of system alerts",
|
||||
metadata={"domain": "core", "type": "alert_type"},
|
||||
)
|
||||
|
||||
register_choices(
|
||||
name="metric_types",
|
||||
choices=METRIC_TYPES,
|
||||
domain="core",
|
||||
description="Types of rate limit metrics",
|
||||
metadata={"domain": "core", "type": "metric_type"},
|
||||
)
|
||||
|
||||
register_choices(
|
||||
name="incident_statuses",
|
||||
choices=INCIDENT_STATUSES,
|
||||
domain="core",
|
||||
description="Incident status options",
|
||||
metadata={"domain": "core", "type": "incident_status"},
|
||||
)
|
||||
|
||||
register_choices(
|
||||
name="alert_sources",
|
||||
choices=ALERT_SOURCES,
|
||||
domain="core",
|
||||
description="Sources of alerts",
|
||||
metadata={"domain": "core", "type": "alert_source"},
|
||||
)
|
||||
|
||||
register_choices(
|
||||
name="pipeline_error_severities",
|
||||
choices=PIPELINE_ERROR_SEVERITIES,
|
||||
domain="core",
|
||||
description="Severity levels for pipeline errors",
|
||||
metadata={"domain": "core", "type": "pipeline_error_severity"},
|
||||
)
|
||||
|
||||
register_choices(
|
||||
name="anomaly_types",
|
||||
choices=ANOMALY_TYPES,
|
||||
domain="core",
|
||||
description="Types of detected anomalies",
|
||||
metadata={"domain": "core", "type": "anomaly_type"},
|
||||
)
|
||||
|
||||
register_choices(
|
||||
name="cleanup_job_statuses",
|
||||
choices=CLEANUP_JOB_STATUSES,
|
||||
domain="core",
|
||||
description="Status options for cleanup jobs",
|
||||
metadata={"domain": "core", "type": "cleanup_job_status"},
|
||||
)
|
||||
|
||||
register_choices(
|
||||
name="date_precision",
|
||||
choices=DATE_PRECISION,
|
||||
domain="core",
|
||||
description="Date precision options",
|
||||
metadata={"domain": "core", "type": "date_precision"},
|
||||
)
|
||||
|
||||
|
||||
# Auto-register choices when module is imported
|
||||
register_core_choices()
|
||||
|
||||
|
||||
133
backend/apps/core/choices/filters.py
Normal file
133
backend/apps/core/choices/filters.py
Normal file
@@ -0,0 +1,133 @@
|
||||
"""
|
||||
Django-filter Integration for Rich Choices
|
||||
|
||||
This module provides django-filter compatible filter classes that integrate
|
||||
with the RichChoice registry system.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from django_filters import ChoiceFilter, MultipleChoiceFilter
|
||||
|
||||
from .registry import registry
|
||||
|
||||
|
||||
class RichChoiceFilter(ChoiceFilter):
|
||||
"""
|
||||
Django-filter ChoiceFilter that uses the RichChoice registry.
|
||||
|
||||
This is the REQUIRED replacement for ChoiceFilter with inline choices.
|
||||
|
||||
Usage:
|
||||
class MyFilterSet(django_filters.FilterSet):
|
||||
status = RichChoiceFilter(
|
||||
choice_group="ticket_statuses",
|
||||
domain="support",
|
||||
)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
choice_group: str,
|
||||
domain: str = "core",
|
||||
allow_deprecated: bool = False,
|
||||
**kwargs
|
||||
):
|
||||
"""
|
||||
Initialize the filter.
|
||||
|
||||
Args:
|
||||
choice_group: Name of the choice group in the registry
|
||||
domain: Domain namespace for the choice group
|
||||
allow_deprecated: Whether to include deprecated choices
|
||||
**kwargs: Additional arguments passed to ChoiceFilter
|
||||
"""
|
||||
self.choice_group = choice_group
|
||||
self.domain = domain
|
||||
self.allow_deprecated = allow_deprecated
|
||||
|
||||
# Get choices from registry
|
||||
if allow_deprecated:
|
||||
choices_list = registry.get_choices(choice_group, domain)
|
||||
else:
|
||||
choices_list = registry.get_active_choices(choice_group, domain)
|
||||
|
||||
choices = [(c.value, c.label) for c in choices_list]
|
||||
|
||||
super().__init__(choices=choices, **kwargs)
|
||||
|
||||
|
||||
class RichMultipleChoiceFilter(MultipleChoiceFilter):
|
||||
"""
|
||||
Django-filter MultipleChoiceFilter that uses the RichChoice registry.
|
||||
|
||||
This is the REQUIRED replacement for MultipleChoiceFilter with inline choices.
|
||||
|
||||
Usage:
|
||||
class MyFilterSet(django_filters.FilterSet):
|
||||
statuses = RichMultipleChoiceFilter(
|
||||
choice_group="ticket_statuses",
|
||||
domain="support",
|
||||
field_name="status",
|
||||
)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
choice_group: str,
|
||||
domain: str = "core",
|
||||
allow_deprecated: bool = False,
|
||||
**kwargs
|
||||
):
|
||||
"""
|
||||
Initialize the filter.
|
||||
|
||||
Args:
|
||||
choice_group: Name of the choice group in the registry
|
||||
domain: Domain namespace for the choice group
|
||||
allow_deprecated: Whether to include deprecated choices
|
||||
**kwargs: Additional arguments passed to MultipleChoiceFilter
|
||||
"""
|
||||
self.choice_group = choice_group
|
||||
self.domain = domain
|
||||
self.allow_deprecated = allow_deprecated
|
||||
|
||||
# Get choices from registry
|
||||
if allow_deprecated:
|
||||
choices_list = registry.get_choices(choice_group, domain)
|
||||
else:
|
||||
choices_list = registry.get_active_choices(choice_group, domain)
|
||||
|
||||
choices = [(c.value, c.label) for c in choices_list]
|
||||
|
||||
super().__init__(choices=choices, **kwargs)
|
||||
|
||||
|
||||
def get_choice_filter_class(
|
||||
choice_group: str,
|
||||
domain: str = "core",
|
||||
allow_deprecated: bool = False,
|
||||
**extra_kwargs: Any
|
||||
) -> type[RichChoiceFilter]:
|
||||
"""
|
||||
Factory function to create a RichChoiceFilter class with preset choices.
|
||||
|
||||
Useful when you need to define the filter class dynamically or
|
||||
when the choice_group/domain aren't available at class definition time.
|
||||
|
||||
Usage:
|
||||
StatusFilter = get_choice_filter_class("ticket_statuses", "support")
|
||||
|
||||
class MyFilterSet(django_filters.FilterSet):
|
||||
status = StatusFilter()
|
||||
"""
|
||||
class DynamicRichChoiceFilter(RichChoiceFilter):
|
||||
def __init__(self, **kwargs):
|
||||
kwargs.setdefault("choice_group", choice_group)
|
||||
kwargs.setdefault("domain", domain)
|
||||
kwargs.setdefault("allow_deprecated", allow_deprecated)
|
||||
for key, value in extra_kwargs.items():
|
||||
kwargs.setdefault(key, value)
|
||||
super().__init__(**kwargs)
|
||||
|
||||
return DynamicRichChoiceFilter
|
||||
@@ -265,3 +265,98 @@ def serialize_choice_value(value: str, choice_group: str, domain: str = "core",
|
||||
}
|
||||
else:
|
||||
return value
|
||||
|
||||
|
||||
class RichChoiceSerializerField(serializers.ChoiceField):
|
||||
"""
|
||||
DRF serializer field for RichChoice values.
|
||||
|
||||
This field validates input against the RichChoice registry and provides
|
||||
type-safe choice handling with proper error messages. It is the REQUIRED
|
||||
replacement for serializers.ChoiceField with inline choices.
|
||||
|
||||
Usage:
|
||||
class MySerializer(serializers.Serializer):
|
||||
status = RichChoiceSerializerField(
|
||||
choice_group="ticket_statuses",
|
||||
domain="support",
|
||||
)
|
||||
|
||||
# With rich metadata in output
|
||||
severity = RichChoiceSerializerField(
|
||||
choice_group="severity_levels",
|
||||
domain="core",
|
||||
include_metadata=True,
|
||||
)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
choice_group: str,
|
||||
domain: str = "core",
|
||||
include_metadata: bool = False,
|
||||
allow_deprecated: bool = False,
|
||||
**kwargs
|
||||
):
|
||||
"""
|
||||
Initialize the serializer field.
|
||||
|
||||
Args:
|
||||
choice_group: Name of the choice group in the registry
|
||||
domain: Domain namespace for the choice group
|
||||
include_metadata: Whether to include rich choice metadata in output
|
||||
allow_deprecated: Whether to allow deprecated choices
|
||||
**kwargs: Additional arguments passed to ChoiceField
|
||||
"""
|
||||
self.choice_group = choice_group
|
||||
self.domain = domain
|
||||
self.include_metadata = include_metadata
|
||||
self.allow_deprecated = allow_deprecated
|
||||
|
||||
# Get choices from registry for validation
|
||||
if allow_deprecated:
|
||||
choices_list = registry.get_choices(choice_group, domain)
|
||||
else:
|
||||
choices_list = registry.get_active_choices(choice_group, domain)
|
||||
|
||||
# Build choices tuple for DRF ChoiceField
|
||||
choices = [(c.value, c.label) for c in choices_list]
|
||||
|
||||
# Store valid values for error messages
|
||||
self._valid_values = [c.value for c in choices_list]
|
||||
|
||||
super().__init__(choices=choices, **kwargs)
|
||||
|
||||
def to_representation(self, value: str) -> Any:
|
||||
"""Convert choice value to representation."""
|
||||
if not value:
|
||||
return value
|
||||
|
||||
if self.include_metadata:
|
||||
return serialize_choice_value(
|
||||
value,
|
||||
self.choice_group,
|
||||
self.domain,
|
||||
include_metadata=True
|
||||
)
|
||||
return value
|
||||
|
||||
def to_internal_value(self, data: Any) -> str:
|
||||
"""Convert input data to choice value."""
|
||||
# Handle rich choice object input (value dict)
|
||||
if isinstance(data, dict) and "value" in data:
|
||||
data = data["value"]
|
||||
|
||||
# Validate and return
|
||||
return super().to_internal_value(data)
|
||||
|
||||
def fail(self, key: str, **kwargs: Any) -> None:
|
||||
"""Provide better error messages with valid choices listed."""
|
||||
if key == "invalid_choice":
|
||||
valid_choices = ", ".join(self._valid_values)
|
||||
raise serializers.ValidationError(
|
||||
f"'{kwargs.get('input', '')}' is not a valid choice for {self.choice_group}. "
|
||||
f"Valid choices are: {valid_choices}"
|
||||
)
|
||||
super().fail(key, **kwargs)
|
||||
|
||||
|
||||
@@ -39,15 +39,30 @@ class AuthRateLimitMiddleware:
|
||||
# Login endpoints
|
||||
"/api/v1/auth/login/": {"per_minute": 5, "per_hour": 30, "per_day": 100},
|
||||
"/accounts/login/": {"per_minute": 5, "per_hour": 30, "per_day": 100},
|
||||
# MFA verification (strict limits - 6-digit codes have limited entropy)
|
||||
"/api/v1/auth/login/mfa-verify/": {"per_minute": 5, "per_hour": 15, "per_day": 50},
|
||||
"/api/v1/auth/mfa/totp/verify/": {"per_minute": 5, "per_hour": 15, "per_day": 50},
|
||||
"/api/v1/auth/mfa/totp/activate/": {"per_minute": 3, "per_hour": 10, "per_day": 30},
|
||||
"/api/v1/auth/mfa/totp/deactivate/": {"per_minute": 3, "per_hour": 10, "per_day": 20},
|
||||
# Passkey endpoints
|
||||
"/api/v1/auth/passkey/authenticate/": {"per_minute": 10, "per_hour": 30, "per_day": 100},
|
||||
"/api/v1/auth/passkey/register/": {"per_minute": 5, "per_hour": 15, "per_day": 30},
|
||||
# Signup endpoints
|
||||
"/api/v1/auth/signup/": {"per_minute": 3, "per_hour": 10, "per_day": 20},
|
||||
"/accounts/signup/": {"per_minute": 3, "per_hour": 10, "per_day": 20},
|
||||
# Password reset endpoints
|
||||
"/api/v1/auth/password-reset/": {"per_minute": 2, "per_hour": 5, "per_day": 10},
|
||||
"/accounts/password/reset/": {"per_minute": 2, "per_hour": 5, "per_day": 10},
|
||||
# Password change (prevent brute force on current password)
|
||||
"/api/v1/auth/password/change/": {"per_minute": 3, "per_hour": 10, "per_day": 30},
|
||||
# Token endpoints
|
||||
"/api/v1/auth/token/": {"per_minute": 10, "per_hour": 60, "per_day": 200},
|
||||
"/api/v1/auth/token/refresh/": {"per_minute": 20, "per_hour": 120, "per_day": 500},
|
||||
# Social account management
|
||||
"/api/v1/auth/social/connect/google/": {"per_minute": 5, "per_hour": 15, "per_day": 30},
|
||||
"/api/v1/auth/social/connect/discord/": {"per_minute": 5, "per_hour": 15, "per_day": 30},
|
||||
"/api/v1/auth/social/disconnect/google/": {"per_minute": 5, "per_hour": 15, "per_day": 20},
|
||||
"/api/v1/auth/social/disconnect/discord/": {"per_minute": 5, "per_hour": 15, "per_day": 20},
|
||||
}
|
||||
|
||||
def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]):
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
# Generated by Django 5.2.10 on 2026-01-11 00:48
|
||||
|
||||
import apps.core.choices.fields
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0010_add_milestone_model'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='applicationerror',
|
||||
name='severity',
|
||||
field=apps.core.choices.fields.RichChoiceField(allow_deprecated=False, choice_group='severity_levels', choices=[('critical', 'Critical'), ('high', 'High'), ('medium', 'Medium'), ('low', 'Low')], db_index=True, default='medium', domain='core', help_text='Error severity level', max_length=20),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='applicationerror',
|
||||
name='source',
|
||||
field=apps.core.choices.fields.RichChoiceField(allow_deprecated=False, choice_group='error_sources', choices=[('frontend', 'Frontend'), ('backend', 'Backend'), ('api', 'API')], db_index=True, domain='core', help_text='Where the error originated', max_length=20),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='incident',
|
||||
name='severity',
|
||||
field=apps.core.choices.fields.RichChoiceField(allow_deprecated=False, choice_group='severity_levels', choices=[('critical', 'Critical'), ('high', 'High'), ('medium', 'Medium'), ('low', 'Low')], db_index=True, domain='core', help_text='Incident severity level', max_length=20),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='incident',
|
||||
name='status',
|
||||
field=apps.core.choices.fields.RichChoiceField(allow_deprecated=False, choice_group='incident_statuses', choices=[('open', 'Open'), ('investigating', 'Investigating'), ('resolved', 'Resolved'), ('closed', 'Closed')], db_index=True, default='open', domain='core', help_text='Current incident status', max_length=20),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='incidentalert',
|
||||
name='alert_source',
|
||||
field=apps.core.choices.fields.RichChoiceField(allow_deprecated=False, choice_group='alert_sources', choices=[('system', 'System Alert'), ('rate_limit', 'Rate Limit Alert')], domain='core', help_text='Source type of the alert', max_length=20),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='milestone',
|
||||
name='event_date_precision',
|
||||
field=apps.core.choices.fields.RichChoiceField(allow_deprecated=False, choice_group='date_precision', choices=[('exact', 'Exact Date'), ('month', 'Month and Year'), ('year', 'Year Only'), ('decade', 'Decade'), ('century', 'Century'), ('approximate', 'Approximate')], default='exact', domain='core', help_text='Precision of the event date', max_length=20),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='milestoneevent',
|
||||
name='event_date_precision',
|
||||
field=apps.core.choices.fields.RichChoiceField(allow_deprecated=False, choice_group='date_precision', choices=[('exact', 'Exact Date'), ('month', 'Month and Year'), ('year', 'Year Only'), ('decade', 'Decade'), ('century', 'Century'), ('approximate', 'Approximate')], default='exact', domain='core', help_text='Precision of the event date', max_length=20),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='ratelimitalertconfig',
|
||||
name='metric_type',
|
||||
field=apps.core.choices.fields.RichChoiceField(allow_deprecated=False, choice_group='metric_types', choices=[('block_rate', 'Block Rate'), ('total_requests', 'Total Requests'), ('unique_ips', 'Unique IPs'), ('function_specific', 'Function Specific')], db_index=True, domain='core', help_text='Type of metric to monitor', max_length=50),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='requestmetadata',
|
||||
name='severity',
|
||||
field=apps.core.choices.fields.RichChoiceField(allow_deprecated=False, choice_group='request_severity_levels', choices=[('debug', 'Debug'), ('info', 'Info'), ('warning', 'Warning'), ('error', 'Error'), ('critical', 'Critical')], db_index=True, default='info', domain='core', help_text='Error severity level', max_length=20),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='systemalert',
|
||||
name='alert_type',
|
||||
field=apps.core.choices.fields.RichChoiceField(allow_deprecated=False, choice_group='system_alert_types', choices=[('orphaned_images', 'Orphaned Images'), ('stale_submissions', 'Stale Submissions'), ('circular_dependency', 'Circular Dependency'), ('validation_error', 'Validation Error'), ('ban_attempt', 'Ban Attempt'), ('upload_timeout', 'Upload Timeout'), ('high_error_rate', 'High Error Rate'), ('database_connection', 'Database Connection'), ('memory_usage', 'Memory Usage'), ('queue_backup', 'Queue Backup')], db_index=True, domain='core', help_text='Type of system alert', max_length=50),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='systemalert',
|
||||
name='severity',
|
||||
field=apps.core.choices.fields.RichChoiceField(allow_deprecated=False, choice_group='severity_levels', choices=[('critical', 'Critical'), ('high', 'High'), ('medium', 'Medium'), ('low', 'Low')], db_index=True, domain='core', help_text='Alert severity level', max_length=20),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,320 @@
|
||||
# Generated by Django 5.2.10 on 2026-01-11 18:06
|
||||
|
||||
import apps.core.choices.fields
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("core", "0011_alter_applicationerror_severity_and_more"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="AlertCorrelationRule",
|
||||
fields=[
|
||||
("id", models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
(
|
||||
"rule_name",
|
||||
models.CharField(
|
||||
db_index=True, help_text="Unique name for this correlation rule", max_length=255, unique=True
|
||||
),
|
||||
),
|
||||
(
|
||||
"rule_description",
|
||||
models.TextField(blank=True, help_text="Description of what this rule correlates"),
|
||||
),
|
||||
(
|
||||
"min_alerts_required",
|
||||
models.PositiveIntegerField(
|
||||
default=3, help_text="Minimum number of alerts needed to trigger correlation"
|
||||
),
|
||||
),
|
||||
(
|
||||
"time_window_minutes",
|
||||
models.PositiveIntegerField(default=30, help_text="Time window in minutes for alert correlation"),
|
||||
),
|
||||
(
|
||||
"incident_severity",
|
||||
apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
choice_group="severity_levels",
|
||||
choices=[("critical", "Critical"), ("high", "High"), ("medium", "Medium"), ("low", "Low")],
|
||||
default="medium",
|
||||
domain="core",
|
||||
help_text="Severity to assign to correlated incidents",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
(
|
||||
"incident_title_template",
|
||||
models.CharField(
|
||||
help_text="Template for incident title (supports {count}, {rule_name})", max_length=255
|
||||
),
|
||||
),
|
||||
(
|
||||
"is_active",
|
||||
models.BooleanField(db_index=True, default=True, help_text="Whether this rule is currently active"),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True, help_text="When this rule was created")),
|
||||
("updated_at", models.DateTimeField(auto_now=True, help_text="When this rule was last updated")),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Alert Correlation Rule",
|
||||
"verbose_name_plural": "Alert Correlation Rules",
|
||||
"ordering": ["rule_name"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="CleanupJobLog",
|
||||
fields=[
|
||||
("id", models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
("job_name", models.CharField(db_index=True, help_text="Name of the cleanup job", max_length=255)),
|
||||
(
|
||||
"status",
|
||||
apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
choice_group="cleanup_job_statuses",
|
||||
choices=[
|
||||
("success", "Success"),
|
||||
("failed", "Failed"),
|
||||
("partial", "Partial"),
|
||||
("skipped", "Skipped"),
|
||||
],
|
||||
db_index=True,
|
||||
default="success",
|
||||
domain="core",
|
||||
help_text="Execution status",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
("records_processed", models.PositiveIntegerField(default=0, help_text="Number of records processed")),
|
||||
("records_deleted", models.PositiveIntegerField(default=0, help_text="Number of records deleted")),
|
||||
("error_message", models.TextField(blank=True, help_text="Error message if job failed", null=True)),
|
||||
(
|
||||
"duration_ms",
|
||||
models.PositiveIntegerField(blank=True, help_text="Execution duration in milliseconds", null=True),
|
||||
),
|
||||
(
|
||||
"executed_at",
|
||||
models.DateTimeField(auto_now_add=True, db_index=True, help_text="When this job was executed"),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Cleanup Job Log",
|
||||
"verbose_name_plural": "Cleanup Job Logs",
|
||||
"ordering": ["-executed_at"],
|
||||
"indexes": [
|
||||
models.Index(fields=["job_name", "executed_at"], name="core_cleanu_job_nam_4530fd_idx"),
|
||||
models.Index(fields=["status", "executed_at"], name="core_cleanu_status_fa6360_idx"),
|
||||
],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="Anomaly",
|
||||
fields=[
|
||||
("id", models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
(
|
||||
"metric_name",
|
||||
models.CharField(
|
||||
db_index=True, help_text="Name of the metric that exhibited anomalous behavior", max_length=255
|
||||
),
|
||||
),
|
||||
(
|
||||
"metric_category",
|
||||
models.CharField(
|
||||
db_index=True,
|
||||
help_text="Category of the metric (e.g., 'performance', 'error_rate', 'traffic')",
|
||||
max_length=100,
|
||||
),
|
||||
),
|
||||
(
|
||||
"anomaly_type",
|
||||
apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
choice_group="anomaly_types",
|
||||
choices=[
|
||||
("spike", "Spike"),
|
||||
("drop", "Drop"),
|
||||
("trend_change", "Trend Change"),
|
||||
("outlier", "Outlier"),
|
||||
("threshold_breach", "Threshold Breach"),
|
||||
],
|
||||
db_index=True,
|
||||
domain="core",
|
||||
help_text="Type of anomaly detected",
|
||||
max_length=30,
|
||||
),
|
||||
),
|
||||
(
|
||||
"severity",
|
||||
apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
choice_group="severity_levels",
|
||||
choices=[("critical", "Critical"), ("high", "High"), ("medium", "Medium"), ("low", "Low")],
|
||||
db_index=True,
|
||||
domain="core",
|
||||
help_text="Severity of the anomaly",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
(
|
||||
"anomaly_value",
|
||||
models.DecimalField(decimal_places=6, help_text="The anomalous value detected", max_digits=20),
|
||||
),
|
||||
(
|
||||
"baseline_value",
|
||||
models.DecimalField(decimal_places=6, help_text="The expected baseline value", max_digits=20),
|
||||
),
|
||||
(
|
||||
"deviation_score",
|
||||
models.DecimalField(decimal_places=4, help_text="Standard deviations from normal", max_digits=10),
|
||||
),
|
||||
(
|
||||
"confidence_score",
|
||||
models.DecimalField(
|
||||
decimal_places=4, help_text="Confidence score of the detection (0-1)", max_digits=5
|
||||
),
|
||||
),
|
||||
("detection_algorithm", models.CharField(help_text="Algorithm used for detection", max_length=100)),
|
||||
("time_window_start", models.DateTimeField(help_text="Start of the detection time window")),
|
||||
("time_window_end", models.DateTimeField(help_text="End of the detection time window")),
|
||||
(
|
||||
"alert_created",
|
||||
models.BooleanField(
|
||||
db_index=True, default=False, help_text="Whether an alert was created for this anomaly"
|
||||
),
|
||||
),
|
||||
(
|
||||
"detected_at",
|
||||
models.DateTimeField(auto_now_add=True, db_index=True, help_text="When this anomaly was detected"),
|
||||
),
|
||||
(
|
||||
"alert",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
help_text="Linked system alert if created",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="anomalies",
|
||||
to="core.systemalert",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Anomaly",
|
||||
"verbose_name_plural": "Anomalies",
|
||||
"ordering": ["-detected_at"],
|
||||
"indexes": [
|
||||
models.Index(fields=["metric_name", "detected_at"], name="core_anomal_metric__06c3c9_idx"),
|
||||
models.Index(fields=["severity", "detected_at"], name="core_anomal_severit_ea7a17_idx"),
|
||||
models.Index(fields=["anomaly_type", "detected_at"], name="core_anomal_anomaly_eb45f7_idx"),
|
||||
models.Index(fields=["alert_created", "detected_at"], name="core_anomal_alert_c_5a0c1a_idx"),
|
||||
],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="PipelineError",
|
||||
fields=[
|
||||
("id", models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
(
|
||||
"function_name",
|
||||
models.CharField(
|
||||
db_index=True, help_text="Name of the function/pipeline that failed", max_length=255
|
||||
),
|
||||
),
|
||||
("error_message", models.TextField(help_text="Error message describing the failure")),
|
||||
(
|
||||
"error_code",
|
||||
models.CharField(
|
||||
blank=True, db_index=True, help_text="Error code for categorization", max_length=100, null=True
|
||||
),
|
||||
),
|
||||
("error_context", models.JSONField(blank=True, help_text="Additional context data as JSON", null=True)),
|
||||
("stack_trace", models.TextField(blank=True, help_text="Full stack trace for debugging", null=True)),
|
||||
(
|
||||
"severity",
|
||||
apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
choice_group="pipeline_error_severities",
|
||||
choices=[
|
||||
("critical", "Critical"),
|
||||
("error", "Error"),
|
||||
("warning", "Warning"),
|
||||
("info", "Info"),
|
||||
],
|
||||
db_index=True,
|
||||
default="error",
|
||||
domain="core",
|
||||
help_text="Severity level of the error",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
(
|
||||
"submission_id",
|
||||
models.UUIDField(
|
||||
blank=True, db_index=True, help_text="ID of related content submission if applicable", null=True
|
||||
),
|
||||
),
|
||||
(
|
||||
"item_id",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
db_index=True,
|
||||
help_text="Generic reference to related item",
|
||||
max_length=255,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"request_id",
|
||||
models.UUIDField(blank=True, db_index=True, help_text="Request ID for correlation", null=True),
|
||||
),
|
||||
("trace_id", models.UUIDField(blank=True, db_index=True, help_text="Distributed trace ID", null=True)),
|
||||
(
|
||||
"resolved",
|
||||
models.BooleanField(db_index=True, default=False, help_text="Whether this error has been resolved"),
|
||||
),
|
||||
(
|
||||
"resolved_at",
|
||||
models.DateTimeField(
|
||||
blank=True, db_index=True, help_text="When this error was resolved", null=True
|
||||
),
|
||||
),
|
||||
(
|
||||
"resolution_notes",
|
||||
models.TextField(blank=True, help_text="Notes about how the error was resolved", null=True),
|
||||
),
|
||||
(
|
||||
"occurred_at",
|
||||
models.DateTimeField(auto_now_add=True, db_index=True, help_text="When this error occurred"),
|
||||
),
|
||||
(
|
||||
"resolved_by",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
help_text="User who resolved this error",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="resolved_pipeline_errors",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Pipeline Error",
|
||||
"verbose_name_plural": "Pipeline Errors",
|
||||
"ordering": ["-occurred_at"],
|
||||
"indexes": [
|
||||
models.Index(fields=["severity", "occurred_at"], name="core_pipeli_severit_9c8037_idx"),
|
||||
models.Index(fields=["function_name", "occurred_at"], name="core_pipeli_functio_efb015_idx"),
|
||||
models.Index(fields=["resolved", "occurred_at"], name="core_pipeli_resolve_cd60c5_idx"),
|
||||
],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -8,8 +8,12 @@ from django.contrib.contenttypes.models import ContentType
|
||||
from django.db import models
|
||||
from django.utils.text import slugify
|
||||
|
||||
from apps.core.choices.fields import RichChoiceField
|
||||
from apps.core.history import TrackedModel
|
||||
|
||||
# Import choices module to ensure registration on app load
|
||||
from apps.core.choices import core_choices # noqa: F401
|
||||
|
||||
|
||||
@pghistory.track()
|
||||
class SlugHistory(models.Model):
|
||||
@@ -136,17 +140,6 @@ class ApplicationError(models.Model):
|
||||
reported via API (frontend) and displayed in the admin dashboard.
|
||||
"""
|
||||
|
||||
class Severity(models.TextChoices):
|
||||
CRITICAL = "critical", "Critical"
|
||||
HIGH = "high", "High"
|
||||
MEDIUM = "medium", "Medium"
|
||||
LOW = "low", "Low"
|
||||
|
||||
class Source(models.TextChoices):
|
||||
FRONTEND = "frontend", "Frontend"
|
||||
BACKEND = "backend", "Backend"
|
||||
API = "api", "API"
|
||||
|
||||
# Identity
|
||||
error_id = models.UUIDField(
|
||||
unique=True,
|
||||
@@ -180,16 +173,18 @@ class ApplicationError(models.Model):
|
||||
db_index=True,
|
||||
help_text="Application-specific error code",
|
||||
)
|
||||
severity = models.CharField(
|
||||
severity = RichChoiceField(
|
||||
choice_group="severity_levels",
|
||||
domain="core",
|
||||
max_length=20,
|
||||
choices=Severity.choices,
|
||||
default=Severity.MEDIUM,
|
||||
default="medium",
|
||||
db_index=True,
|
||||
help_text="Error severity level",
|
||||
)
|
||||
source = models.CharField(
|
||||
source = RichChoiceField(
|
||||
choice_group="error_sources",
|
||||
domain="core",
|
||||
max_length=20,
|
||||
choices=Source.choices,
|
||||
db_index=True,
|
||||
help_text="Where the error originated",
|
||||
)
|
||||
@@ -308,34 +303,18 @@ class SystemAlert(models.Model):
|
||||
validation errors, ban attempts, upload timeouts, and high error rates.
|
||||
"""
|
||||
|
||||
class AlertType(models.TextChoices):
|
||||
ORPHANED_IMAGES = "orphaned_images", "Orphaned Images"
|
||||
STALE_SUBMISSIONS = "stale_submissions", "Stale Submissions"
|
||||
CIRCULAR_DEPENDENCY = "circular_dependency", "Circular Dependency"
|
||||
VALIDATION_ERROR = "validation_error", "Validation Error"
|
||||
BAN_ATTEMPT = "ban_attempt", "Ban Attempt"
|
||||
UPLOAD_TIMEOUT = "upload_timeout", "Upload Timeout"
|
||||
HIGH_ERROR_RATE = "high_error_rate", "High Error Rate"
|
||||
DATABASE_CONNECTION = "database_connection", "Database Connection"
|
||||
MEMORY_USAGE = "memory_usage", "Memory Usage"
|
||||
QUEUE_BACKUP = "queue_backup", "Queue Backup"
|
||||
|
||||
class Severity(models.TextChoices):
|
||||
LOW = "low", "Low"
|
||||
MEDIUM = "medium", "Medium"
|
||||
HIGH = "high", "High"
|
||||
CRITICAL = "critical", "Critical"
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
alert_type = models.CharField(
|
||||
alert_type = RichChoiceField(
|
||||
choice_group="system_alert_types",
|
||||
domain="core",
|
||||
max_length=50,
|
||||
choices=AlertType.choices,
|
||||
db_index=True,
|
||||
help_text="Type of system alert",
|
||||
)
|
||||
severity = models.CharField(
|
||||
severity = RichChoiceField(
|
||||
choice_group="severity_levels",
|
||||
domain="core",
|
||||
max_length=20,
|
||||
choices=Severity.choices,
|
||||
db_index=True,
|
||||
help_text="Alert severity level",
|
||||
)
|
||||
@@ -386,16 +365,11 @@ class RateLimitAlertConfig(models.Model):
|
||||
Defines thresholds that trigger alerts when exceeded.
|
||||
"""
|
||||
|
||||
class MetricType(models.TextChoices):
|
||||
BLOCK_RATE = "block_rate", "Block Rate"
|
||||
TOTAL_REQUESTS = "total_requests", "Total Requests"
|
||||
UNIQUE_IPS = "unique_ips", "Unique IPs"
|
||||
FUNCTION_SPECIFIC = "function_specific", "Function Specific"
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
metric_type = models.CharField(
|
||||
metric_type = RichChoiceField(
|
||||
choice_group="metric_types",
|
||||
domain="core",
|
||||
max_length=50,
|
||||
choices=MetricType.choices,
|
||||
db_index=True,
|
||||
help_text="Type of metric to monitor",
|
||||
)
|
||||
@@ -484,18 +458,6 @@ class Incident(models.Model):
|
||||
allowing teams to track and resolve related alerts together.
|
||||
"""
|
||||
|
||||
class Status(models.TextChoices):
|
||||
OPEN = "open", "Open"
|
||||
INVESTIGATING = "investigating", "Investigating"
|
||||
RESOLVED = "resolved", "Resolved"
|
||||
CLOSED = "closed", "Closed"
|
||||
|
||||
class Severity(models.TextChoices):
|
||||
LOW = "low", "Low"
|
||||
MEDIUM = "medium", "Medium"
|
||||
HIGH = "high", "High"
|
||||
CRITICAL = "critical", "Critical"
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
incident_number = models.CharField(
|
||||
max_length=20,
|
||||
@@ -505,16 +467,18 @@ class Incident(models.Model):
|
||||
)
|
||||
title = models.CharField(max_length=255, help_text="Brief description of the incident")
|
||||
description = models.TextField(null=True, blank=True, help_text="Detailed description")
|
||||
severity = models.CharField(
|
||||
severity = RichChoiceField(
|
||||
choice_group="severity_levels",
|
||||
domain="core",
|
||||
max_length=20,
|
||||
choices=Severity.choices,
|
||||
db_index=True,
|
||||
help_text="Incident severity level",
|
||||
)
|
||||
status = models.CharField(
|
||||
status = RichChoiceField(
|
||||
choice_group="incident_statuses",
|
||||
domain="core",
|
||||
max_length=20,
|
||||
choices=Status.choices,
|
||||
default=Status.OPEN,
|
||||
default="open",
|
||||
db_index=True,
|
||||
help_text="Current incident status",
|
||||
)
|
||||
@@ -582,10 +546,6 @@ class IncidentAlert(models.Model):
|
||||
Supports linking both system alerts and rate limit alerts.
|
||||
"""
|
||||
|
||||
class AlertSource(models.TextChoices):
|
||||
SYSTEM = "system", "System Alert"
|
||||
RATE_LIMIT = "rate_limit", "Rate Limit Alert"
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
incident = models.ForeignKey(
|
||||
Incident,
|
||||
@@ -593,9 +553,10 @@ class IncidentAlert(models.Model):
|
||||
related_name="linked_alerts",
|
||||
help_text="The incident this alert is linked to",
|
||||
)
|
||||
alert_source = models.CharField(
|
||||
alert_source = RichChoiceField(
|
||||
choice_group="alert_sources",
|
||||
domain="core",
|
||||
max_length=20,
|
||||
choices=AlertSource.choices,
|
||||
help_text="Source type of the alert",
|
||||
)
|
||||
alert_id = models.UUIDField(help_text="ID of the linked alert")
|
||||
@@ -633,13 +594,6 @@ class RequestMetadata(models.Model):
|
||||
dashboard for error monitoring and analytics.
|
||||
"""
|
||||
|
||||
class Severity(models.TextChoices):
|
||||
DEBUG = "debug", "Debug"
|
||||
INFO = "info", "Info"
|
||||
WARNING = "warning", "Warning"
|
||||
ERROR = "error", "Error"
|
||||
CRITICAL = "critical", "Critical"
|
||||
|
||||
# Identity & Correlation
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
request_id = models.CharField(
|
||||
@@ -789,10 +743,11 @@ class RequestMetadata(models.Model):
|
||||
null=True,
|
||||
help_text="React component stack trace",
|
||||
)
|
||||
severity = models.CharField(
|
||||
severity = RichChoiceField(
|
||||
choice_group="request_severity_levels",
|
||||
domain="core",
|
||||
max_length=20,
|
||||
choices=Severity.choices,
|
||||
default=Severity.INFO,
|
||||
default="info",
|
||||
db_index=True,
|
||||
help_text="Error severity level",
|
||||
)
|
||||
@@ -1062,14 +1017,6 @@ class Milestone(TrackedModel):
|
||||
Maps to frontend milestoneValidationSchema in entityValidationSchemas.ts
|
||||
"""
|
||||
|
||||
class DatePrecision(models.TextChoices):
|
||||
EXACT = "exact", "Exact Date"
|
||||
MONTH = "month", "Month and Year"
|
||||
YEAR = "year", "Year Only"
|
||||
DECADE = "decade", "Decade"
|
||||
CENTURY = "century", "Century"
|
||||
APPROXIMATE = "approximate", "Approximate"
|
||||
|
||||
# Core event information
|
||||
title = models.CharField(
|
||||
max_length=200,
|
||||
@@ -1088,10 +1035,11 @@ class Milestone(TrackedModel):
|
||||
db_index=True,
|
||||
help_text="Date when the event occurred or will occur",
|
||||
)
|
||||
event_date_precision = models.CharField(
|
||||
event_date_precision = RichChoiceField(
|
||||
choice_group="date_precision",
|
||||
domain="core",
|
||||
max_length=20,
|
||||
choices=DatePrecision.choices,
|
||||
default=DatePrecision.EXACT,
|
||||
default="exact",
|
||||
help_text="Precision of the event date",
|
||||
)
|
||||
|
||||
@@ -1161,3 +1109,354 @@ class Milestone(TrackedModel):
|
||||
def __str__(self) -> str:
|
||||
return f"{self.title} ({self.event_date})"
|
||||
|
||||
|
||||
class PipelineError(models.Model):
|
||||
"""
|
||||
Tracks pipeline/processing errors for debugging and monitoring.
|
||||
|
||||
Records errors that occur during data processing pipelines,
|
||||
approval workflows, and other automated operations. Supports
|
||||
resolution tracking and filtering by severity, function, and date.
|
||||
"""
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
function_name = models.CharField(
|
||||
max_length=255,
|
||||
db_index=True,
|
||||
help_text="Name of the function/pipeline that failed",
|
||||
)
|
||||
error_message = models.TextField(
|
||||
help_text="Error message describing the failure",
|
||||
)
|
||||
error_code = models.CharField(
|
||||
max_length=100,
|
||||
blank=True,
|
||||
null=True,
|
||||
db_index=True,
|
||||
help_text="Error code for categorization",
|
||||
)
|
||||
error_context = models.JSONField(
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text="Additional context data as JSON",
|
||||
)
|
||||
stack_trace = models.TextField(
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text="Full stack trace for debugging",
|
||||
)
|
||||
severity = RichChoiceField(
|
||||
choice_group="pipeline_error_severities",
|
||||
domain="core",
|
||||
max_length=20,
|
||||
default="error",
|
||||
db_index=True,
|
||||
help_text="Severity level of the error",
|
||||
)
|
||||
|
||||
# References
|
||||
submission_id = models.UUIDField(
|
||||
blank=True,
|
||||
null=True,
|
||||
db_index=True,
|
||||
help_text="ID of related content submission if applicable",
|
||||
)
|
||||
item_id = models.CharField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
null=True,
|
||||
db_index=True,
|
||||
help_text="Generic reference to related item",
|
||||
)
|
||||
request_id = models.UUIDField(
|
||||
blank=True,
|
||||
null=True,
|
||||
db_index=True,
|
||||
help_text="Request ID for correlation",
|
||||
)
|
||||
trace_id = models.UUIDField(
|
||||
blank=True,
|
||||
null=True,
|
||||
db_index=True,
|
||||
help_text="Distributed trace ID",
|
||||
)
|
||||
|
||||
# Resolution
|
||||
resolved = models.BooleanField(
|
||||
default=False,
|
||||
db_index=True,
|
||||
help_text="Whether this error has been resolved",
|
||||
)
|
||||
resolved_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="resolved_pipeline_errors",
|
||||
help_text="User who resolved this error",
|
||||
)
|
||||
resolved_at = models.DateTimeField(
|
||||
blank=True,
|
||||
null=True,
|
||||
db_index=True,
|
||||
help_text="When this error was resolved",
|
||||
)
|
||||
resolution_notes = models.TextField(
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text="Notes about how the error was resolved",
|
||||
)
|
||||
|
||||
# Timestamps
|
||||
occurred_at = models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
db_index=True,
|
||||
help_text="When this error occurred",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-occurred_at"]
|
||||
verbose_name = "Pipeline Error"
|
||||
verbose_name_plural = "Pipeline Errors"
|
||||
indexes = [
|
||||
models.Index(fields=["severity", "occurred_at"]),
|
||||
models.Index(fields=["function_name", "occurred_at"]),
|
||||
models.Index(fields=["resolved", "occurred_at"]),
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"[{self.get_severity_display()}] {self.function_name}: {self.error_message[:50]}"
|
||||
|
||||
|
||||
class Anomaly(models.Model):
|
||||
"""
|
||||
Records detected anomalies in system metrics.
|
||||
|
||||
Anomalies are identified by detection algorithms and stored
|
||||
for analysis and alerting. Each anomaly includes the metric
|
||||
details, deviation scores, and optional links to created alerts.
|
||||
"""
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
metric_name = models.CharField(
|
||||
max_length=255,
|
||||
db_index=True,
|
||||
help_text="Name of the metric that exhibited anomalous behavior",
|
||||
)
|
||||
metric_category = models.CharField(
|
||||
max_length=100,
|
||||
db_index=True,
|
||||
help_text="Category of the metric (e.g., 'performance', 'error_rate', 'traffic')",
|
||||
)
|
||||
anomaly_type = RichChoiceField(
|
||||
choice_group="anomaly_types",
|
||||
domain="core",
|
||||
max_length=30,
|
||||
db_index=True,
|
||||
help_text="Type of anomaly detected",
|
||||
)
|
||||
severity = RichChoiceField(
|
||||
choice_group="severity_levels",
|
||||
domain="core",
|
||||
max_length=20,
|
||||
db_index=True,
|
||||
help_text="Severity of the anomaly",
|
||||
)
|
||||
|
||||
# Metric values
|
||||
anomaly_value = models.DecimalField(
|
||||
max_digits=20,
|
||||
decimal_places=6,
|
||||
help_text="The anomalous value detected",
|
||||
)
|
||||
baseline_value = models.DecimalField(
|
||||
max_digits=20,
|
||||
decimal_places=6,
|
||||
help_text="The expected baseline value",
|
||||
)
|
||||
deviation_score = models.DecimalField(
|
||||
max_digits=10,
|
||||
decimal_places=4,
|
||||
help_text="Standard deviations from normal",
|
||||
)
|
||||
confidence_score = models.DecimalField(
|
||||
max_digits=5,
|
||||
decimal_places=4,
|
||||
help_text="Confidence score of the detection (0-1)",
|
||||
)
|
||||
|
||||
# Detection context
|
||||
detection_algorithm = models.CharField(
|
||||
max_length=100,
|
||||
help_text="Algorithm used for detection",
|
||||
)
|
||||
time_window_start = models.DateTimeField(
|
||||
help_text="Start of the detection time window",
|
||||
)
|
||||
time_window_end = models.DateTimeField(
|
||||
help_text="End of the detection time window",
|
||||
)
|
||||
|
||||
# Alert linkage
|
||||
alert_created = models.BooleanField(
|
||||
default=False,
|
||||
db_index=True,
|
||||
help_text="Whether an alert was created for this anomaly",
|
||||
)
|
||||
alert = models.ForeignKey(
|
||||
SystemAlert,
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="anomalies",
|
||||
help_text="Linked system alert if created",
|
||||
)
|
||||
|
||||
# Timestamps
|
||||
detected_at = models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
db_index=True,
|
||||
help_text="When this anomaly was detected",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-detected_at"]
|
||||
verbose_name = "Anomaly"
|
||||
verbose_name_plural = "Anomalies"
|
||||
indexes = [
|
||||
models.Index(fields=["metric_name", "detected_at"]),
|
||||
models.Index(fields=["severity", "detected_at"]),
|
||||
models.Index(fields=["anomaly_type", "detected_at"]),
|
||||
models.Index(fields=["alert_created", "detected_at"]),
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"[{self.get_severity_display()}] {self.metric_name}: {self.get_anomaly_type_display()}"
|
||||
|
||||
|
||||
class AlertCorrelationRule(models.Model):
|
||||
"""
|
||||
Defines rules for correlating multiple alerts.
|
||||
|
||||
When multiple alerts match a correlation rule's pattern within
|
||||
the time window, they can be grouped into an incident. This
|
||||
helps reduce alert fatigue and identify related issues.
|
||||
"""
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
rule_name = models.CharField(
|
||||
max_length=255,
|
||||
unique=True,
|
||||
db_index=True,
|
||||
help_text="Unique name for this correlation rule",
|
||||
)
|
||||
rule_description = models.TextField(
|
||||
blank=True,
|
||||
help_text="Description of what this rule correlates",
|
||||
)
|
||||
min_alerts_required = models.PositiveIntegerField(
|
||||
default=3,
|
||||
help_text="Minimum number of alerts needed to trigger correlation",
|
||||
)
|
||||
time_window_minutes = models.PositiveIntegerField(
|
||||
default=30,
|
||||
help_text="Time window in minutes for alert correlation",
|
||||
)
|
||||
incident_severity = RichChoiceField(
|
||||
choice_group="severity_levels",
|
||||
domain="core",
|
||||
max_length=20,
|
||||
default="medium",
|
||||
help_text="Severity to assign to correlated incidents",
|
||||
)
|
||||
incident_title_template = models.CharField(
|
||||
max_length=255,
|
||||
help_text="Template for incident title (supports {count}, {rule_name})",
|
||||
)
|
||||
is_active = models.BooleanField(
|
||||
default=True,
|
||||
db_index=True,
|
||||
help_text="Whether this rule is currently active",
|
||||
)
|
||||
|
||||
# Timestamps
|
||||
created_at = models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
help_text="When this rule was created",
|
||||
)
|
||||
updated_at = models.DateTimeField(
|
||||
auto_now=True,
|
||||
help_text="When this rule was last updated",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ["rule_name"]
|
||||
verbose_name = "Alert Correlation Rule"
|
||||
verbose_name_plural = "Alert Correlation Rules"
|
||||
|
||||
def __str__(self) -> str:
|
||||
status = "active" if self.is_active else "inactive"
|
||||
return f"{self.rule_name} ({status})"
|
||||
|
||||
|
||||
class CleanupJobLog(models.Model):
|
||||
"""
|
||||
Audit log for cleanup and maintenance jobs.
|
||||
|
||||
Records the execution of background cleanup tasks,
|
||||
including success/failure status, records processed,
|
||||
and execution time for monitoring and debugging.
|
||||
"""
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
job_name = models.CharField(
|
||||
max_length=255,
|
||||
db_index=True,
|
||||
help_text="Name of the cleanup job",
|
||||
)
|
||||
status = RichChoiceField(
|
||||
choice_group="cleanup_job_statuses",
|
||||
domain="core",
|
||||
max_length=20,
|
||||
default="success",
|
||||
db_index=True,
|
||||
help_text="Execution status",
|
||||
)
|
||||
records_processed = models.PositiveIntegerField(
|
||||
default=0,
|
||||
help_text="Number of records processed",
|
||||
)
|
||||
records_deleted = models.PositiveIntegerField(
|
||||
default=0,
|
||||
help_text="Number of records deleted",
|
||||
)
|
||||
error_message = models.TextField(
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text="Error message if job failed",
|
||||
)
|
||||
duration_ms = models.PositiveIntegerField(
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text="Execution duration in milliseconds",
|
||||
)
|
||||
|
||||
# Timestamps
|
||||
executed_at = models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
db_index=True,
|
||||
help_text="When this job was executed",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-executed_at"]
|
||||
verbose_name = "Cleanup Job Log"
|
||||
verbose_name_plural = "Cleanup Job Logs"
|
||||
indexes = [
|
||||
models.Index(fields=["job_name", "executed_at"]),
|
||||
models.Index(fields=["status", "executed_at"]),
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.job_name}: {self.get_status_display()} ({self.records_processed} processed)"
|
||||
|
||||
|
||||
@@ -55,3 +55,45 @@ def get_direct_upload_url(user_id=None):
|
||||
raise e
|
||||
|
||||
return result.get("result", {})
|
||||
|
||||
|
||||
def delete_cloudflare_image(image_id: str) -> bool:
|
||||
"""
|
||||
Delete an image from Cloudflare Images.
|
||||
|
||||
Used to cleanup orphaned images when submissions are rejected or deleted.
|
||||
|
||||
Args:
|
||||
image_id: The Cloudflare image ID to delete.
|
||||
|
||||
Returns:
|
||||
bool: True if deletion succeeded, False otherwise.
|
||||
"""
|
||||
account_id = getattr(settings, "CLOUDFLARE_IMAGES_ACCOUNT_ID", None)
|
||||
api_token = getattr(settings, "CLOUDFLARE_IMAGES_API_TOKEN", None)
|
||||
|
||||
if not account_id or not api_token:
|
||||
logger.error("Cloudflare settings missing, cannot delete image %s", image_id)
|
||||
return False
|
||||
|
||||
url = f"https://api.cloudflare.com/client/v4/accounts/{account_id}/images/v1/{image_id}"
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {api_token}",
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.delete(url, headers=headers)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
|
||||
if result.get("success"):
|
||||
logger.info("Successfully deleted Cloudflare image: %s", image_id)
|
||||
return True
|
||||
else:
|
||||
error_msg = result.get("errors", [{"message": "Unknown error"}])[0].get("message")
|
||||
logger.warning("Failed to delete Cloudflare image %s: %s", image_id, error_msg)
|
||||
return False
|
||||
except requests.RequestException as e:
|
||||
capture_and_log(e, f"Delete Cloudflare image {image_id}", source="service")
|
||||
return False
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
Django admin configuration for the Moderation application.
|
||||
|
||||
This module provides comprehensive admin interfaces for content moderation
|
||||
including edit submissions, photo submissions, and state transition logs.
|
||||
including edit submissions and state transition logs.
|
||||
Includes a custom moderation admin site for dedicated moderation workflows.
|
||||
|
||||
Performance targets:
|
||||
@@ -18,7 +18,7 @@ from django.utils.html import format_html
|
||||
from django.utils.safestring import mark_safe
|
||||
from django_fsm_log.models import StateLog
|
||||
|
||||
from .models import EditSubmission, PhotoSubmission
|
||||
from .models import EditSubmission
|
||||
|
||||
|
||||
class ModerationAdminSite(AdminSite):
|
||||
@@ -52,13 +52,13 @@ class ModerationAdminSite(AdminSite):
|
||||
|
||||
# Get pending counts
|
||||
extra_context["pending_edits"] = EditSubmission.objects.filter(status="PENDING").count()
|
||||
extra_context["pending_photos"] = PhotoSubmission.objects.filter(status="PENDING").count()
|
||||
extra_context["pending_photos"] = EditSubmission.objects.filter(submission_type="PHOTO", status="PENDING").count()
|
||||
|
||||
# Get recent activity
|
||||
extra_context["recent_edits"] = EditSubmission.objects.select_related("user", "handled_by").order_by(
|
||||
"-created_at"
|
||||
)[:5]
|
||||
extra_context["recent_photos"] = PhotoSubmission.objects.select_related("user", "handled_by").order_by(
|
||||
extra_context["recent_photos"] = EditSubmission.objects.filter(submission_type="PHOTO").select_related("user", "handled_by").order_by(
|
||||
"-created_at"
|
||||
)[:5]
|
||||
|
||||
@@ -307,198 +307,6 @@ class EditSubmissionAdmin(admin.ModelAdmin):
|
||||
return actions
|
||||
|
||||
|
||||
class PhotoSubmissionAdmin(admin.ModelAdmin):
|
||||
"""
|
||||
Admin interface for photo submission moderation.
|
||||
|
||||
Provides photo submission management with:
|
||||
- Image preview in list view
|
||||
- Bulk approve/reject actions
|
||||
- FSM-aware status handling
|
||||
- User and content linking
|
||||
|
||||
Query optimizations:
|
||||
- select_related: user, content_type, handled_by
|
||||
"""
|
||||
|
||||
list_display = (
|
||||
"id",
|
||||
"user_link",
|
||||
"content_type_display",
|
||||
"content_link",
|
||||
"photo_preview",
|
||||
"status_badge",
|
||||
"created_at",
|
||||
"handled_by_link",
|
||||
)
|
||||
list_filter = ("status", "content_type", "created_at")
|
||||
list_select_related = ["user", "content_type", "handled_by"]
|
||||
search_fields = ("user__username", "caption", "notes", "object_id")
|
||||
readonly_fields = (
|
||||
"user",
|
||||
"content_type",
|
||||
"object_id",
|
||||
"photo_preview",
|
||||
"created_at",
|
||||
)
|
||||
list_per_page = 50
|
||||
show_full_result_count = False
|
||||
ordering = ("-created_at",)
|
||||
date_hierarchy = "created_at"
|
||||
|
||||
fieldsets = (
|
||||
(
|
||||
"Submission Details",
|
||||
{
|
||||
"fields": ("user", "content_type", "object_id"),
|
||||
"description": "Who submitted what.",
|
||||
},
|
||||
),
|
||||
(
|
||||
"Photo",
|
||||
{
|
||||
"fields": ("photo", "photo_preview", "caption"),
|
||||
"description": "The submitted photo.",
|
||||
},
|
||||
),
|
||||
(
|
||||
"Status",
|
||||
{
|
||||
"fields": ("status", "handled_by", "notes"),
|
||||
"description": "Current status and moderation notes.",
|
||||
},
|
||||
),
|
||||
(
|
||||
"Metadata",
|
||||
{
|
||||
"fields": ("created_at",),
|
||||
"classes": ("collapse",),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
@admin.display(description="User")
|
||||
def user_link(self, obj):
|
||||
"""Display user as clickable link."""
|
||||
if obj.user:
|
||||
try:
|
||||
url = reverse("admin:accounts_customuser_change", args=[obj.user.id])
|
||||
return format_html('<a href="{}">{}</a>', url, obj.user.username)
|
||||
except Exception:
|
||||
return obj.user.username
|
||||
return "-"
|
||||
|
||||
@admin.display(description="Type")
|
||||
def content_type_display(self, obj):
|
||||
"""Display content type in a readable format."""
|
||||
if obj.content_type:
|
||||
return f"{obj.content_type.app_label}.{obj.content_type.model}"
|
||||
return "-"
|
||||
|
||||
@admin.display(description="Content")
|
||||
def content_link(self, obj):
|
||||
"""Display content object as clickable link."""
|
||||
try:
|
||||
content_obj = obj.content_object
|
||||
if content_obj:
|
||||
if hasattr(content_obj, "get_absolute_url"):
|
||||
url = content_obj.get_absolute_url()
|
||||
return format_html('<a href="{}">{}</a>', url, str(content_obj)[:30])
|
||||
return str(content_obj)[:30]
|
||||
except Exception:
|
||||
pass
|
||||
return format_html('<span style="color: red;">Not found</span>')
|
||||
|
||||
@admin.display(description="Preview")
|
||||
def photo_preview(self, obj):
|
||||
"""Display photo preview thumbnail."""
|
||||
if obj.photo:
|
||||
return format_html(
|
||||
'<img src="{}" style="max-height: 80px; max-width: 150px; '
|
||||
'border-radius: 4px; object-fit: cover;" loading="lazy" />',
|
||||
obj.photo.url,
|
||||
)
|
||||
return format_html('<span style="color: gray;">No photo</span>')
|
||||
|
||||
@admin.display(description="Status")
|
||||
def status_badge(self, obj):
|
||||
"""Display status with color-coded badge."""
|
||||
colors = {
|
||||
"PENDING": "orange",
|
||||
"APPROVED": "green",
|
||||
"REJECTED": "red",
|
||||
}
|
||||
color = colors.get(obj.status, "gray")
|
||||
return format_html(
|
||||
'<span style="background-color: {}; color: white; padding: 2px 8px; '
|
||||
'border-radius: 4px; font-size: 11px;">{}</span>',
|
||||
color,
|
||||
obj.status,
|
||||
)
|
||||
|
||||
@admin.display(description="Handled By")
|
||||
def handled_by_link(self, obj):
|
||||
"""Display handler as clickable link."""
|
||||
if obj.handled_by:
|
||||
try:
|
||||
url = reverse("admin:accounts_customuser_change", args=[obj.handled_by.id])
|
||||
return format_html('<a href="{}">{}</a>', url, obj.handled_by.username)
|
||||
except Exception:
|
||||
return obj.handled_by.username
|
||||
return "-"
|
||||
|
||||
def save_model(self, request, obj, form, change):
|
||||
"""Handle FSM transitions on status change."""
|
||||
if "status" in form.changed_data:
|
||||
try:
|
||||
if obj.status == "APPROVED":
|
||||
obj.approve(request.user, obj.notes)
|
||||
elif obj.status == "REJECTED":
|
||||
obj.reject(request.user, obj.notes)
|
||||
except Exception as e:
|
||||
messages.error(request, f"Status transition failed: {str(e)}")
|
||||
return
|
||||
super().save_model(request, obj, form, change)
|
||||
|
||||
@admin.action(description="Approve selected photos")
|
||||
def bulk_approve(self, request, queryset):
|
||||
"""Approve all selected pending photo submissions."""
|
||||
count = 0
|
||||
for submission in queryset.filter(status="PENDING"):
|
||||
try:
|
||||
submission.approve(request.user, "Bulk approved")
|
||||
count += 1
|
||||
except Exception:
|
||||
pass
|
||||
self.message_user(request, f"Approved {count} photo submissions.")
|
||||
|
||||
@admin.action(description="Reject selected photos")
|
||||
def bulk_reject(self, request, queryset):
|
||||
"""Reject all selected pending photo submissions."""
|
||||
count = 0
|
||||
for submission in queryset.filter(status="PENDING"):
|
||||
try:
|
||||
submission.reject(request.user, "Bulk rejected")
|
||||
count += 1
|
||||
except Exception:
|
||||
pass
|
||||
self.message_user(request, f"Rejected {count} photo submissions.")
|
||||
|
||||
def get_actions(self, request):
|
||||
"""Add moderation actions."""
|
||||
actions = super().get_actions(request)
|
||||
actions["bulk_approve"] = (
|
||||
self.bulk_approve,
|
||||
"bulk_approve",
|
||||
"Approve selected photos",
|
||||
)
|
||||
actions["bulk_reject"] = (
|
||||
self.bulk_reject,
|
||||
"bulk_reject",
|
||||
"Reject selected photos",
|
||||
)
|
||||
return actions
|
||||
|
||||
|
||||
class StateLogAdmin(admin.ModelAdmin):
|
||||
"""
|
||||
@@ -754,7 +562,6 @@ class HistoryEventAdmin(admin.ModelAdmin):
|
||||
|
||||
# Register with moderation site only
|
||||
moderation_site.register(EditSubmission, EditSubmissionAdmin)
|
||||
moderation_site.register(PhotoSubmission, PhotoSubmissionAdmin)
|
||||
moderation_site.register(StateLog, StateLogAdmin)
|
||||
|
||||
# Note: Concrete pghistory event models would be registered as they are created
|
||||
|
||||
@@ -25,7 +25,6 @@ class ModerationConfig(AppConfig):
|
||||
EditSubmission,
|
||||
ModerationQueue,
|
||||
ModerationReport,
|
||||
PhotoSubmission,
|
||||
)
|
||||
|
||||
# Apply FSM to all models with their respective choice groups
|
||||
@@ -53,12 +52,6 @@ class ModerationConfig(AppConfig):
|
||||
choice_group="bulk_operation_statuses",
|
||||
domain="moderation",
|
||||
)
|
||||
apply_state_machine(
|
||||
PhotoSubmission,
|
||||
field_name="status",
|
||||
choice_group="photo_submission_statuses",
|
||||
domain="moderation",
|
||||
)
|
||||
|
||||
def _register_callbacks(self):
|
||||
"""Register FSM transition callbacks for moderation models."""
|
||||
@@ -78,7 +71,6 @@ class ModerationConfig(AppConfig):
|
||||
EditSubmission,
|
||||
ModerationQueue,
|
||||
ModerationReport,
|
||||
PhotoSubmission,
|
||||
)
|
||||
|
||||
# EditSubmission callbacks (transitions from CLAIMED state)
|
||||
@@ -88,14 +80,6 @@ class ModerationConfig(AppConfig):
|
||||
register_callback(EditSubmission, "status", "CLAIMED", "REJECTED", ModerationCacheInvalidation())
|
||||
register_callback(EditSubmission, "status", "CLAIMED", "ESCALATED", SubmissionEscalatedNotification())
|
||||
register_callback(EditSubmission, "status", "CLAIMED", "ESCALATED", ModerationCacheInvalidation())
|
||||
|
||||
# PhotoSubmission callbacks (transitions from CLAIMED state)
|
||||
register_callback(PhotoSubmission, "status", "CLAIMED", "APPROVED", SubmissionApprovedNotification())
|
||||
register_callback(PhotoSubmission, "status", "CLAIMED", "APPROVED", ModerationCacheInvalidation())
|
||||
register_callback(PhotoSubmission, "status", "CLAIMED", "REJECTED", SubmissionRejectedNotification())
|
||||
register_callback(PhotoSubmission, "status", "CLAIMED", "REJECTED", ModerationCacheInvalidation())
|
||||
register_callback(PhotoSubmission, "status", "CLAIMED", "ESCALATED", SubmissionEscalatedNotification())
|
||||
|
||||
# ModerationReport callbacks
|
||||
register_callback(ModerationReport, "status", "*", "*", ModerationNotificationCallback())
|
||||
register_callback(ModerationReport, "status", "*", "*", ModerationCacheInvalidation())
|
||||
|
||||
@@ -124,6 +124,20 @@ SUBMISSION_TYPES = [
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION,
|
||||
),
|
||||
RichChoice(
|
||||
value="PHOTO",
|
||||
label="Photo Submission",
|
||||
description="Photo upload for existing content",
|
||||
metadata={
|
||||
"color": "purple",
|
||||
"icon": "photograph",
|
||||
"css_class": "bg-purple-100 text-purple-800 border-purple-200",
|
||||
"sort_order": 3,
|
||||
"requires_existing_object": True,
|
||||
"complexity_level": "low",
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION,
|
||||
),
|
||||
]
|
||||
|
||||
# ============================================================================
|
||||
@@ -934,6 +948,122 @@ BULK_OPERATION_TYPES = [
|
||||
# PhotoSubmission uses the same STATUS_CHOICES as EditSubmission
|
||||
PHOTO_SUBMISSION_STATUSES = EDIT_SUBMISSION_STATUSES
|
||||
|
||||
# ============================================================================
|
||||
# ModerationAuditLog Action Choices
|
||||
# ============================================================================
|
||||
|
||||
MODERATION_AUDIT_ACTIONS = [
|
||||
RichChoice(
|
||||
value="approved",
|
||||
label="Approved",
|
||||
description="Submission was approved by moderator",
|
||||
metadata={
|
||||
"color": "green",
|
||||
"icon": "check-circle",
|
||||
"css_class": "bg-green-100 text-green-800",
|
||||
"sort_order": 1,
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION,
|
||||
),
|
||||
RichChoice(
|
||||
value="rejected",
|
||||
label="Rejected",
|
||||
description="Submission was rejected by moderator",
|
||||
metadata={
|
||||
"color": "red",
|
||||
"icon": "x-circle",
|
||||
"css_class": "bg-red-100 text-red-800",
|
||||
"sort_order": 2,
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION,
|
||||
),
|
||||
RichChoice(
|
||||
value="claimed",
|
||||
label="Claimed",
|
||||
description="Submission was claimed by moderator",
|
||||
metadata={
|
||||
"color": "blue",
|
||||
"icon": "user-check",
|
||||
"css_class": "bg-blue-100 text-blue-800",
|
||||
"sort_order": 3,
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION,
|
||||
),
|
||||
RichChoice(
|
||||
value="unclaimed",
|
||||
label="Unclaimed",
|
||||
description="Submission was released by moderator",
|
||||
metadata={
|
||||
"color": "gray",
|
||||
"icon": "user-minus",
|
||||
"css_class": "bg-gray-100 text-gray-800",
|
||||
"sort_order": 4,
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION,
|
||||
),
|
||||
RichChoice(
|
||||
value="escalated",
|
||||
label="Escalated",
|
||||
description="Submission was escalated for higher-level review",
|
||||
metadata={
|
||||
"color": "purple",
|
||||
"icon": "arrow-up",
|
||||
"css_class": "bg-purple-100 text-purple-800",
|
||||
"sort_order": 5,
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION,
|
||||
),
|
||||
RichChoice(
|
||||
value="converted_to_edit",
|
||||
label="Converted to Edit",
|
||||
description="Photo submission was converted to an edit submission",
|
||||
metadata={
|
||||
"color": "indigo",
|
||||
"icon": "refresh",
|
||||
"css_class": "bg-indigo-100 text-indigo-800",
|
||||
"sort_order": 6,
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION,
|
||||
),
|
||||
RichChoice(
|
||||
value="status_changed",
|
||||
label="Status Changed",
|
||||
description="Submission status was changed",
|
||||
metadata={
|
||||
"color": "yellow",
|
||||
"icon": "refresh-cw",
|
||||
"css_class": "bg-yellow-100 text-yellow-800",
|
||||
"sort_order": 7,
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION,
|
||||
),
|
||||
RichChoice(
|
||||
value="notes_added",
|
||||
label="Notes Added",
|
||||
description="Moderator notes were added to submission",
|
||||
metadata={
|
||||
"color": "blue",
|
||||
"icon": "edit",
|
||||
"css_class": "bg-blue-100 text-blue-800",
|
||||
"sort_order": 8,
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION,
|
||||
),
|
||||
RichChoice(
|
||||
value="auto_approved",
|
||||
label="Auto Approved",
|
||||
description="Submission was auto-approved by the system",
|
||||
metadata={
|
||||
"color": "green",
|
||||
"icon": "zap",
|
||||
"css_class": "bg-green-100 text-green-800",
|
||||
"sort_order": 9,
|
||||
"is_system_action": True,
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION,
|
||||
),
|
||||
]
|
||||
|
||||
# ============================================================================
|
||||
# Choice Registration
|
||||
# ============================================================================
|
||||
@@ -958,3 +1088,6 @@ register_choices("bulk_operation_types", BULK_OPERATION_TYPES, "moderation", "Bu
|
||||
register_choices(
|
||||
"photo_submission_statuses", PHOTO_SUBMISSION_STATUSES, "moderation", "Photo submission status options"
|
||||
)
|
||||
register_choices(
|
||||
"moderation_audit_actions", MODERATION_AUDIT_ACTIONS, "moderation", "Moderation audit log action types"
|
||||
)
|
||||
|
||||
@@ -27,12 +27,10 @@ User = get_user_model()
|
||||
class ModerationReportFilter(django_filters.FilterSet):
|
||||
"""Filter for ModerationReport model."""
|
||||
|
||||
# Status filters
|
||||
status = django_filters.ChoiceFilter(
|
||||
choices=lambda: [
|
||||
(choice.value, choice.label) for choice in get_choices("moderation_report_statuses", "moderation")
|
||||
],
|
||||
help_text="Filter by report status",
|
||||
# Status filters - use method filter for case-insensitive matching
|
||||
status = django_filters.CharFilter(
|
||||
method="filter_status",
|
||||
help_text="Filter by report status (case-insensitive)",
|
||||
)
|
||||
|
||||
# Priority filters
|
||||
@@ -144,6 +142,19 @@ class ModerationReportFilter(django_filters.FilterSet):
|
||||
return queryset.exclude(resolution_action__isnull=True, resolution_action="")
|
||||
return queryset.filter(Q(resolution_action__isnull=True) | Q(resolution_action=""))
|
||||
|
||||
def filter_status(self, queryset, name, value):
|
||||
"""Filter by status with case-insensitive matching."""
|
||||
if not value:
|
||||
return queryset
|
||||
# Normalize to uppercase for matching against RichChoice values
|
||||
normalized_value = value.upper()
|
||||
# Validate against registered choices
|
||||
valid_values = {choice.value for choice in get_choices("moderation_report_statuses", "moderation")}
|
||||
if normalized_value in valid_values:
|
||||
return queryset.filter(status=normalized_value)
|
||||
# If not valid, return empty queryset (invalid filter value)
|
||||
return queryset.none()
|
||||
|
||||
|
||||
class ModerationQueueFilter(django_filters.FilterSet):
|
||||
"""Filter for ModerationQueue model."""
|
||||
|
||||
@@ -33,7 +33,7 @@ class Command(BaseCommand):
|
||||
def handle(self, *args, **options):
|
||||
from datetime import timedelta
|
||||
from django.utils import timezone
|
||||
from apps.moderation.models import EditSubmission, PhotoSubmission
|
||||
from apps.moderation.models import EditSubmission
|
||||
|
||||
minutes = options["minutes"]
|
||||
dry_run = options["dry_run"]
|
||||
@@ -47,8 +47,9 @@ class Command(BaseCommand):
|
||||
status="CLAIMED",
|
||||
claimed_at__lt=cutoff_time,
|
||||
).select_related("claimed_by")
|
||||
|
||||
stale_photo = PhotoSubmission.objects.filter(
|
||||
# Also find PHOTO type EditSubmissions
|
||||
stale_photo = EditSubmission.objects.filter(
|
||||
submission_type="PHOTO",
|
||||
status="CLAIMED",
|
||||
claimed_at__lt=cutoff_time,
|
||||
).select_related("claimed_by")
|
||||
@@ -66,7 +67,7 @@ class Command(BaseCommand):
|
||||
f" - ID {sub.id}: claimed by {sub.claimed_by} at {sub.claimed_at}"
|
||||
)
|
||||
|
||||
self.stdout.write(f"Found {stale_photo_count} stale PhotoSubmission claims:")
|
||||
self.stdout.write(f"Found {stale_photo_count} stale PHOTO submission claims:")
|
||||
for sub in stale_photo:
|
||||
self.stdout.write(
|
||||
f" - ID {sub.id}: claimed by {sub.claimed_by} at {sub.claimed_at}"
|
||||
@@ -84,10 +85,6 @@ class Command(BaseCommand):
|
||||
f" EditSubmissions: {result['edit_submissions']['released']} released, "
|
||||
f"{result['edit_submissions']['failed']} failed"
|
||||
)
|
||||
self.stdout.write(
|
||||
f" PhotoSubmissions: {result['photo_submissions']['released']} released, "
|
||||
f"{result['photo_submissions']['failed']} failed"
|
||||
)
|
||||
|
||||
if result["failures"]:
|
||||
self.stdout.write(self.style.ERROR("\nFailures:"))
|
||||
|
||||
@@ -5,7 +5,7 @@ from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from apps.moderation.models import EditSubmission, PhotoSubmission
|
||||
from apps.moderation.models import EditSubmission
|
||||
from apps.parks.models import Park
|
||||
from apps.rides.models import Ride
|
||||
|
||||
@@ -218,40 +218,38 @@ class Command(BaseCommand):
|
||||
status="PENDING",
|
||||
)
|
||||
|
||||
# Create PhotoSubmissions with detailed captions
|
||||
# Create PHOTO submissions using EditSubmission with submission_type=PHOTO
|
||||
|
||||
# Park photo submission
|
||||
image_data = (
|
||||
b"GIF87a\x01\x00\x01\x00\x80\x01\x00\x00\x00\x00ccc,\x00\x00\x00\x00\x01\x00\x01\x00\x00\x02\x02D\x01\x00;"
|
||||
)
|
||||
dummy_image = SimpleUploadedFile("park_entrance.gif", image_data, content_type="image/gif")
|
||||
|
||||
PhotoSubmission.objects.create(
|
||||
EditSubmission.objects.create(
|
||||
user=user,
|
||||
content_type=park_ct,
|
||||
object_id=test_park.id,
|
||||
photo=dummy_image,
|
||||
submission_type="PHOTO",
|
||||
changes={}, # No field changes for photos
|
||||
caption=(
|
||||
"Main entrance plaza of Test Park showing the newly installed digital display board "
|
||||
"and renovated ticketing area. Photo taken during morning park opening."
|
||||
),
|
||||
date_taken=date(2024, 1, 15),
|
||||
status="PENDING",
|
||||
reason="Photo of park entrance",
|
||||
)
|
||||
|
||||
# Ride photo submission
|
||||
dummy_image2 = SimpleUploadedFile("coaster_track.gif", image_data, content_type="image/gif")
|
||||
PhotoSubmission.objects.create(
|
||||
EditSubmission.objects.create(
|
||||
user=user,
|
||||
content_type=ride_ct,
|
||||
object_id=test_ride.id,
|
||||
photo=dummy_image2,
|
||||
submission_type="PHOTO",
|
||||
changes={}, # No field changes for photos
|
||||
caption=(
|
||||
"Test Coaster's first drop and loop element showing the new paint scheme. "
|
||||
"Photo taken from the guest pathway near Station Alpha."
|
||||
),
|
||||
date_taken=date(2024, 1, 20),
|
||||
status="PENDING",
|
||||
reason="Photo of ride",
|
||||
)
|
||||
|
||||
self.stdout.write(self.style.SUCCESS("Successfully seeded test submissions"))
|
||||
|
||||
@@ -9,7 +9,6 @@ from apps.moderation.models import (
|
||||
EditSubmission,
|
||||
ModerationQueue,
|
||||
ModerationReport,
|
||||
PhotoSubmission,
|
||||
)
|
||||
|
||||
|
||||
@@ -28,8 +27,7 @@ class Command(BaseCommand):
|
||||
type=str,
|
||||
help=(
|
||||
"Validate only specific model "
|
||||
"(editsubmission, moderationreport, moderationqueue, "
|
||||
"bulkoperation, photosubmission)"
|
||||
"(editsubmission, moderationreport, moderationqueue, bulkoperation)"
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
@@ -65,11 +63,7 @@ class Command(BaseCommand):
|
||||
"bulk_operation_statuses",
|
||||
"moderation",
|
||||
),
|
||||
"photosubmission": (
|
||||
PhotoSubmission,
|
||||
"photo_submission_statuses",
|
||||
"moderation",
|
||||
),
|
||||
# Note: PhotoSubmission removed - photos now handled via EditSubmission
|
||||
}
|
||||
|
||||
# Filter by model name if specified
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
# Generated by Django 5.2.10 on 2026-01-11 18:06
|
||||
|
||||
import apps.core.choices.fields
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("moderation", "0009_add_claim_fields"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="ModerationAuditLog",
|
||||
fields=[
|
||||
("id", models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
(
|
||||
"action",
|
||||
apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
choice_group="moderation_audit_actions",
|
||||
choices=[
|
||||
("approved", "Approved"),
|
||||
("rejected", "Rejected"),
|
||||
("claimed", "Claimed"),
|
||||
("unclaimed", "Unclaimed"),
|
||||
("escalated", "Escalated"),
|
||||
("converted_to_edit", "Converted to Edit"),
|
||||
("status_changed", "Status Changed"),
|
||||
("notes_added", "Notes Added"),
|
||||
("auto_approved", "Auto Approved"),
|
||||
],
|
||||
db_index=True,
|
||||
domain="moderation",
|
||||
help_text="The action that was performed",
|
||||
max_length=50,
|
||||
),
|
||||
),
|
||||
(
|
||||
"previous_status",
|
||||
models.CharField(blank=True, help_text="Status before the action", max_length=50, null=True),
|
||||
),
|
||||
(
|
||||
"new_status",
|
||||
models.CharField(blank=True, help_text="Status after the action", max_length=50, null=True),
|
||||
),
|
||||
("notes", models.TextField(blank=True, help_text="Notes or comments about the action", null=True)),
|
||||
(
|
||||
"is_system_action",
|
||||
models.BooleanField(
|
||||
db_index=True, default=False, help_text="Whether this was an automated system action"
|
||||
),
|
||||
),
|
||||
("is_test_data", models.BooleanField(default=False, help_text="Whether this is test data")),
|
||||
(
|
||||
"created_at",
|
||||
models.DateTimeField(auto_now_add=True, db_index=True, help_text="When this action was performed"),
|
||||
),
|
||||
(
|
||||
"moderator",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
help_text="The moderator who performed the action (null for system actions)",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="moderation_audit_logs",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
(
|
||||
"submission",
|
||||
models.ForeignKey(
|
||||
help_text="The submission this audit log entry is for",
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="audit_logs",
|
||||
to="moderation.editsubmission",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Moderation Audit Log",
|
||||
"verbose_name_plural": "Moderation Audit Logs",
|
||||
"ordering": ["-created_at"],
|
||||
"indexes": [
|
||||
models.Index(fields=["submission", "created_at"], name="moderation__submiss_2f5e56_idx"),
|
||||
models.Index(fields=["moderator", "created_at"], name="moderation__moderat_591c14_idx"),
|
||||
models.Index(fields=["action", "created_at"], name="moderation__action_a98c47_idx"),
|
||||
],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,99 @@
|
||||
# Generated by Django 5.2.10 on 2026-01-12 23:00
|
||||
|
||||
import django.db.models.deletion
|
||||
import pgtrigger.compiler
|
||||
import pgtrigger.migrations
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("django_cloudflareimages_toolkit", "0001_initial"),
|
||||
("moderation", "0010_moderationauditlog"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
pgtrigger.migrations.RemoveTrigger(
|
||||
model_name="editsubmission",
|
||||
name="insert_insert",
|
||||
),
|
||||
pgtrigger.migrations.RemoveTrigger(
|
||||
model_name="editsubmission",
|
||||
name="update_update",
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="editsubmission",
|
||||
name="caption",
|
||||
field=models.CharField(blank=True, help_text="Photo caption", max_length=255),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="editsubmission",
|
||||
name="date_taken",
|
||||
field=models.DateField(blank=True, help_text="Date the photo was taken", null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="editsubmission",
|
||||
name="photo",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
help_text="Photo for photo submissions",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
to="django_cloudflareimages_toolkit.cloudflareimage",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="editsubmissionevent",
|
||||
name="caption",
|
||||
field=models.CharField(blank=True, help_text="Photo caption", max_length=255),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="editsubmissionevent",
|
||||
name="date_taken",
|
||||
field=models.DateField(blank=True, help_text="Date the photo was taken", null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="editsubmissionevent",
|
||||
name="photo",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
db_constraint=False,
|
||||
help_text="Photo for photo submissions",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
related_query_name="+",
|
||||
to="django_cloudflareimages_toolkit.cloudflareimage",
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="editsubmission",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="insert_insert",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
func='INSERT INTO "moderation_editsubmissionevent" ("caption", "changes", "claimed_at", "claimed_by_id", "content_type_id", "created_at", "date_taken", "handled_at", "handled_by_id", "id", "moderator_changes", "notes", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "photo_id", "reason", "source", "status", "submission_type", "updated_at", "user_id") VALUES (NEW."caption", NEW."changes", NEW."claimed_at", NEW."claimed_by_id", NEW."content_type_id", NEW."created_at", NEW."date_taken", NEW."handled_at", NEW."handled_by_id", NEW."id", NEW."moderator_changes", NEW."notes", NEW."object_id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."photo_id", NEW."reason", NEW."source", NEW."status", NEW."submission_type", NEW."updated_at", NEW."user_id"); RETURN NULL;',
|
||||
hash="e9aed25fe6389b113919e729543a9abe20d9f30c",
|
||||
operation="INSERT",
|
||||
pgid="pgtrigger_insert_insert_2c796",
|
||||
table="moderation_editsubmission",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="editsubmission",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="update_update",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
|
||||
func='INSERT INTO "moderation_editsubmissionevent" ("caption", "changes", "claimed_at", "claimed_by_id", "content_type_id", "created_at", "date_taken", "handled_at", "handled_by_id", "id", "moderator_changes", "notes", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "photo_id", "reason", "source", "status", "submission_type", "updated_at", "user_id") VALUES (NEW."caption", NEW."changes", NEW."claimed_at", NEW."claimed_by_id", NEW."content_type_id", NEW."created_at", NEW."date_taken", NEW."handled_at", NEW."handled_by_id", NEW."id", NEW."moderator_changes", NEW."notes", NEW."object_id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."photo_id", NEW."reason", NEW."source", NEW."status", NEW."submission_type", NEW."updated_at", NEW."user_id"); RETURN NULL;',
|
||||
hash="070083ba4d2d459067d9c3a90356a759f6262a90",
|
||||
operation="UPDATE",
|
||||
pgid="pgtrigger_update_update_ab38f",
|
||||
table="moderation_editsubmission",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,64 @@
|
||||
"""
|
||||
Data migration to copy PhotoSubmission data to EditSubmission.
|
||||
|
||||
This migration copies all PhotoSubmission rows to EditSubmission with submission_type="PHOTO".
|
||||
After this migration, PhotoSubmission model can be safely removed.
|
||||
"""
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def migrate_photo_submissions(apps, schema_editor):
|
||||
"""Copy PhotoSubmission data to EditSubmission."""
|
||||
PhotoSubmission = apps.get_model("moderation", "PhotoSubmission")
|
||||
EditSubmission = apps.get_model("moderation", "EditSubmission")
|
||||
ContentType = apps.get_model("contenttypes", "ContentType")
|
||||
|
||||
# Get EditSubmission content type for reference
|
||||
edit_submission_ct = ContentType.objects.get_for_model(EditSubmission)
|
||||
|
||||
migrated = 0
|
||||
for photo_sub in PhotoSubmission.objects.all():
|
||||
# Create EditSubmission from PhotoSubmission
|
||||
EditSubmission.objects.create(
|
||||
user=photo_sub.user,
|
||||
content_type=photo_sub.content_type,
|
||||
object_id=photo_sub.object_id,
|
||||
submission_type="PHOTO",
|
||||
changes={}, # Photos don't have field changes
|
||||
reason="Photo submission", # Default reason
|
||||
status=photo_sub.status,
|
||||
created_at=photo_sub.created_at,
|
||||
handled_by=photo_sub.handled_by,
|
||||
handled_at=photo_sub.handled_at,
|
||||
notes=photo_sub.notes,
|
||||
claimed_by=photo_sub.claimed_by,
|
||||
claimed_at=photo_sub.claimed_at,
|
||||
# Photo-specific fields
|
||||
photo=photo_sub.photo,
|
||||
caption=photo_sub.caption,
|
||||
date_taken=photo_sub.date_taken,
|
||||
)
|
||||
migrated += 1
|
||||
|
||||
if migrated:
|
||||
print(f"Migrated {migrated} PhotoSubmission(s) to EditSubmission")
|
||||
|
||||
|
||||
def reverse_migration(apps, schema_editor):
|
||||
"""Remove migrated EditSubmissions with type PHOTO."""
|
||||
EditSubmission = apps.get_model("moderation", "EditSubmission")
|
||||
deleted, _ = EditSubmission.objects.filter(submission_type="PHOTO").delete()
|
||||
if deleted:
|
||||
print(f"Deleted {deleted} PHOTO EditSubmission(s)")
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("moderation", "0011_add_photo_fields_to_editsubmission"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(migrate_photo_submissions, reverse_migration),
|
||||
]
|
||||
@@ -0,0 +1,47 @@
|
||||
# Generated by Django 5.2.10 on 2026-01-13 01:46
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("moderation", "0012_migrate_photo_submissions"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name="photosubmissionevent",
|
||||
name="pgh_obj",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="photosubmissionevent",
|
||||
name="claimed_by",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="photosubmissionevent",
|
||||
name="content_type",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="photosubmissionevent",
|
||||
name="handled_by",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="photosubmissionevent",
|
||||
name="pgh_context",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="photosubmissionevent",
|
||||
name="photo",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="photosubmissionevent",
|
||||
name="user",
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name="PhotoSubmission",
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name="PhotoSubmissionEvent",
|
||||
),
|
||||
]
|
||||
@@ -13,7 +13,7 @@ from django.http import (
|
||||
)
|
||||
from django.views.generic import DetailView
|
||||
|
||||
from .models import EditSubmission, PhotoSubmission, UserType
|
||||
from .models import EditSubmission, UserType
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
@@ -146,6 +146,8 @@ class EditSubmissionMixin(DetailView):
|
||||
class PhotoSubmissionMixin(DetailView):
|
||||
"""
|
||||
Mixin for handling photo submissions with proper moderation.
|
||||
|
||||
Photos are now handled via EditSubmission with submission_type='PHOTO'.
|
||||
"""
|
||||
|
||||
model: type[models.Model] | None = None
|
||||
@@ -177,19 +179,25 @@ class PhotoSubmissionMixin(DetailView):
|
||||
|
||||
content_type = ContentType.objects.get_for_model(obj)
|
||||
|
||||
submission = PhotoSubmission(
|
||||
# Create EditSubmission with PHOTO type
|
||||
submission = EditSubmission(
|
||||
user=request.user,
|
||||
content_type=content_type,
|
||||
object_id=getattr(obj, "id", None),
|
||||
submission_type="PHOTO",
|
||||
changes={}, # No field changes for photos
|
||||
photo=request.FILES["photo"],
|
||||
caption=request.POST.get("caption", ""),
|
||||
date_taken=request.POST.get("date_taken"),
|
||||
reason="Photo submission",
|
||||
)
|
||||
|
||||
# Auto-approve for moderators and above
|
||||
user_role = getattr(request.user, "role", None)
|
||||
if user_role in ["MODERATOR", "ADMIN", "SUPERUSER"]:
|
||||
submission.auto_approve()
|
||||
submission.save()
|
||||
submission.claim(user=request.user)
|
||||
submission.approve(cast(UserType, request.user))
|
||||
return JsonResponse(
|
||||
{
|
||||
"status": "success",
|
||||
|
||||
@@ -18,6 +18,7 @@ are registered via the callback configuration defined in each model's Meta class
|
||||
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
import uuid
|
||||
|
||||
import pghistory
|
||||
from django.conf import settings
|
||||
@@ -114,6 +115,25 @@ class EditSubmission(StateMachineMixin, TrackedModel):
|
||||
help_text="Moderator's edited version of the changes before approval",
|
||||
)
|
||||
|
||||
# Photo submission fields (only used when submission_type="PHOTO")
|
||||
photo = models.ForeignKey(
|
||||
"django_cloudflareimages_toolkit.CloudflareImage",
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Photo for photo submissions",
|
||||
)
|
||||
caption = models.CharField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
help_text="Photo caption",
|
||||
)
|
||||
date_taken = models.DateField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Date the photo was taken",
|
||||
)
|
||||
|
||||
# Metadata
|
||||
reason = models.TextField(help_text="Why this edit/addition is needed")
|
||||
source = models.TextField(blank=True, help_text="Source of information (if applicable)")
|
||||
@@ -190,6 +210,122 @@ class EditSubmission(StateMachineMixin, TrackedModel):
|
||||
"""Get the final changes to apply (moderator changes if available, otherwise original changes)"""
|
||||
return self.moderator_changes or self.changes
|
||||
|
||||
def _get_model_class_for_item_type(self, item_type: str):
|
||||
"""
|
||||
Map item_type string to the corresponding Django model class.
|
||||
|
||||
Args:
|
||||
item_type: Type string from frontend (e.g., 'manufacturer', 'park', 'ride_model')
|
||||
|
||||
Returns:
|
||||
Model class for the item type
|
||||
"""
|
||||
# Lazy imports to avoid circular dependencies
|
||||
from apps.parks.models import Company, Park
|
||||
from apps.rides.models import Ride, RideModel
|
||||
|
||||
type_map = {
|
||||
# Company types (all map to Company model)
|
||||
'manufacturer': Company,
|
||||
'designer': Company,
|
||||
'operator': Company,
|
||||
'property_owner': Company,
|
||||
'company': Company,
|
||||
# Entity types
|
||||
'park': Park,
|
||||
'ride': Ride,
|
||||
'ride_model': RideModel,
|
||||
}
|
||||
|
||||
model_class = type_map.get(item_type.lower())
|
||||
if not model_class:
|
||||
raise ValueError(f"Unknown item_type: {item_type}")
|
||||
return model_class
|
||||
|
||||
def _process_composite_items(self, composite_items: list[dict[str, Any]]) -> dict[int, Any]:
|
||||
"""
|
||||
Process composite submission items (dependencies) before the primary entity.
|
||||
|
||||
Args:
|
||||
composite_items: List of dependency items from frontend's submissionItems array
|
||||
Each item has: item_type, action_type, item_data, order_index, depends_on
|
||||
|
||||
Returns:
|
||||
Dictionary mapping order_index -> created entity ID for resolving temp references
|
||||
"""
|
||||
from django.db import transaction
|
||||
|
||||
# Sort by order_index to ensure proper dependency order
|
||||
sorted_items = sorted(composite_items, key=lambda x: x.get('order_index', 0))
|
||||
|
||||
# Map of order_index -> created entity ID
|
||||
created_entities: dict[int, Any] = {}
|
||||
|
||||
with transaction.atomic():
|
||||
for item in sorted_items:
|
||||
item_type = item.get('item_type', '')
|
||||
item_data = item.get('item_data', {})
|
||||
order_index = item.get('order_index', 0)
|
||||
|
||||
if not item_type or not item_data:
|
||||
continue
|
||||
|
||||
# Get the model class for this item type
|
||||
model_class = self._get_model_class_for_item_type(item_type)
|
||||
|
||||
# Clean up internal fields not needed for model creation
|
||||
clean_data = {}
|
||||
for key, value in item_data.items():
|
||||
# Skip internal/temp fields
|
||||
if key.startswith('_temp_') or key == 'images' or key == '_composite_items':
|
||||
continue
|
||||
# Skip fields with None or 'temp-' values
|
||||
if value is None or (isinstance(value, str) and value.startswith('temp-')):
|
||||
continue
|
||||
clean_data[key] = value
|
||||
|
||||
# Resolve _temp_*_ref fields to actual entity IDs from previously created entities
|
||||
for key, value in item_data.items():
|
||||
if key.startswith('_temp_') and key.endswith('_ref'):
|
||||
# Extract the field name: _temp_manufacturer_ref -> manufacturer_id
|
||||
field_name = key[6:-4] + '_id' # Remove '_temp_' prefix and '_ref' suffix
|
||||
ref_order_index = value
|
||||
if isinstance(ref_order_index, int) and ref_order_index in created_entities:
|
||||
clean_data[field_name] = created_entities[ref_order_index]
|
||||
|
||||
# Resolve foreign keys to model instances
|
||||
resolved_data = {}
|
||||
for field_name, value in clean_data.items():
|
||||
try:
|
||||
field = model_class._meta.get_field(field_name)
|
||||
if isinstance(field, models.ForeignKey) and value is not None:
|
||||
try:
|
||||
related_obj = field.related_model.objects.get(pk=value)
|
||||
resolved_data[field_name] = related_obj
|
||||
except ObjectDoesNotExist:
|
||||
# Skip invalid FK references
|
||||
continue
|
||||
else:
|
||||
resolved_data[field_name] = value
|
||||
except:
|
||||
# Field doesn't exist on model, still try to include it
|
||||
resolved_data[field_name] = value
|
||||
|
||||
# Create the entity
|
||||
try:
|
||||
obj = model_class(**resolved_data)
|
||||
obj.full_clean()
|
||||
obj.save()
|
||||
created_entities[order_index] = obj.pk
|
||||
except Exception as e:
|
||||
# Log but continue - don't fail the whole submission for one dependency
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f"Failed to create composite item {item_type}: {e}")
|
||||
continue
|
||||
|
||||
return created_entities
|
||||
|
||||
def claim(self, user: UserType) -> None:
|
||||
"""
|
||||
Claim this submission for review.
|
||||
@@ -266,16 +402,60 @@ class EditSubmission(StateMachineMixin, TrackedModel):
|
||||
raise ValueError("Could not resolve model class")
|
||||
|
||||
final_changes = self._get_final_changes()
|
||||
|
||||
# Process composite items (dependencies) first if present
|
||||
created_entity_ids: dict[int, Any] = {}
|
||||
if '_composite_items' in final_changes:
|
||||
composite_items = final_changes.pop('_composite_items')
|
||||
if composite_items and isinstance(composite_items, list):
|
||||
created_entity_ids = self._process_composite_items(composite_items)
|
||||
|
||||
# Resolve _temp_*_ref fields in the primary entity using created dependency IDs
|
||||
for key in list(final_changes.keys()):
|
||||
if key.startswith('_temp_') and key.endswith('_ref'):
|
||||
# Extract field name: _temp_manufacturer_ref -> manufacturer_id
|
||||
field_name = key[6:-4] + '_id' # Remove '_temp_' and '_ref'
|
||||
ref_order_index = final_changes.pop(key)
|
||||
if isinstance(ref_order_index, int) and ref_order_index in created_entity_ids:
|
||||
final_changes[field_name] = created_entity_ids[ref_order_index]
|
||||
|
||||
# Remove any remaining internal fields
|
||||
keys_to_remove = [k for k in final_changes.keys() if k.startswith('_')]
|
||||
for key in keys_to_remove:
|
||||
final_changes.pop(key, None)
|
||||
|
||||
resolved_changes = self._resolve_foreign_keys(final_changes)
|
||||
|
||||
try:
|
||||
if self.submission_type == "CREATE":
|
||||
if self.submission_type == "PHOTO":
|
||||
# Handle photo submissions - create ParkPhoto or RidePhoto
|
||||
from apps.parks.models.media import ParkPhoto
|
||||
from apps.rides.models.media import RidePhoto
|
||||
|
||||
# Determine the correct photo model based on content type
|
||||
model_name = model_class.__name__
|
||||
if model_name == "Park":
|
||||
PhotoModel = ParkPhoto
|
||||
elif model_name == "Ride":
|
||||
PhotoModel = RidePhoto
|
||||
else:
|
||||
raise ValueError(f"Unsupported content type for photo: {model_name}")
|
||||
|
||||
# Create the approved photo
|
||||
obj = PhotoModel.objects.create(
|
||||
uploaded_by=self.user,
|
||||
content_object=self.content_object,
|
||||
image=self.photo,
|
||||
caption=self.caption or "",
|
||||
is_approved=True,
|
||||
)
|
||||
elif self.submission_type == "CREATE":
|
||||
# Create new object
|
||||
obj = model_class(**resolved_changes)
|
||||
obj.full_clean()
|
||||
obj.save()
|
||||
else:
|
||||
# Update existing object
|
||||
# Update existing object (EDIT type)
|
||||
if not self.content_object:
|
||||
raise ValueError("Cannot update: content object not found")
|
||||
|
||||
@@ -295,6 +475,7 @@ class EditSubmission(StateMachineMixin, TrackedModel):
|
||||
|
||||
return obj
|
||||
|
||||
|
||||
except Exception as e:
|
||||
# On error, record the issue and attempt rejection transition
|
||||
self.notes = f"Approval failed: {str(e)}"
|
||||
@@ -664,239 +845,84 @@ class BulkOperation(StateMachineMixin, TrackedModel):
|
||||
return round((self.processed_items / self.total_items) * 100, 2)
|
||||
|
||||
|
||||
@pghistory.track() # Track all changes by default
|
||||
class PhotoSubmission(StateMachineMixin, TrackedModel):
|
||||
"""Photo submission model with FSM-managed status transitions."""
|
||||
# NOTE: PhotoSubmission model removed - photos are now handled via
|
||||
# EditSubmission with submission_type="PHOTO". See migration for details.
|
||||
|
||||
state_field_name = "status"
|
||||
|
||||
# Who submitted the photo
|
||||
user = models.ForeignKey(
|
||||
class ModerationAuditLog(models.Model):
|
||||
"""
|
||||
Audit log for moderation actions.
|
||||
|
||||
Records all moderation activities including approvals, rejections,
|
||||
claims, escalations, and conversions for accountability and analytics.
|
||||
"""
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
submission = models.ForeignKey(
|
||||
EditSubmission,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="audit_logs",
|
||||
help_text="The submission this audit log entry is for",
|
||||
)
|
||||
moderator = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="photo_submissions",
|
||||
help_text="User who submitted this photo",
|
||||
)
|
||||
|
||||
# What the photo is for (Park or Ride)
|
||||
content_type = models.ForeignKey(
|
||||
ContentType,
|
||||
on_delete=models.CASCADE,
|
||||
help_text="Type of object this photo is for",
|
||||
)
|
||||
object_id = models.PositiveIntegerField(help_text="ID of object this photo is for")
|
||||
content_object = GenericForeignKey("content_type", "object_id")
|
||||
|
||||
# The photo itself
|
||||
photo = models.ForeignKey(
|
||||
"django_cloudflareimages_toolkit.CloudflareImage",
|
||||
on_delete=models.CASCADE,
|
||||
help_text="Photo submission stored on Cloudflare Images",
|
||||
)
|
||||
caption = models.CharField(max_length=255, blank=True, help_text="Photo caption")
|
||||
date_taken = models.DateField(null=True, blank=True, help_text="Date the photo was taken")
|
||||
|
||||
# Metadata
|
||||
status = RichFSMField(
|
||||
choice_group="photo_submission_statuses", domain="moderation", max_length=20, default="PENDING"
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
# Review details
|
||||
handled_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="handled_photos",
|
||||
help_text="Moderator who handled this submission",
|
||||
null=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="moderation_audit_logs",
|
||||
help_text="The moderator who performed the action (null for system actions)",
|
||||
)
|
||||
action = RichChoiceField(
|
||||
choice_group="moderation_audit_actions",
|
||||
domain="moderation",
|
||||
max_length=50,
|
||||
db_index=True,
|
||||
help_text="The action that was performed",
|
||||
)
|
||||
previous_status = models.CharField(
|
||||
max_length=50,
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text="Status before the action",
|
||||
)
|
||||
new_status = models.CharField(
|
||||
max_length=50,
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text="Status after the action",
|
||||
)
|
||||
handled_at = models.DateTimeField(null=True, blank=True, help_text="When this submission was handled")
|
||||
notes = models.TextField(
|
||||
blank=True,
|
||||
help_text="Notes from the moderator about this photo submission",
|
||||
)
|
||||
|
||||
# Claim tracking for concurrency control
|
||||
claimed_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="claimed_photo_submissions",
|
||||
help_text="Moderator who has claimed this submission for review",
|
||||
help_text="Notes or comments about the action",
|
||||
)
|
||||
is_system_action = models.BooleanField(
|
||||
default=False,
|
||||
db_index=True,
|
||||
help_text="Whether this was an automated system action",
|
||||
)
|
||||
is_test_data = models.BooleanField(
|
||||
default=False,
|
||||
help_text="Whether this is test data",
|
||||
)
|
||||
claimed_at = models.DateTimeField(null=True, blank=True, help_text="When this submission was claimed")
|
||||
|
||||
class Meta(TrackedModel.Meta):
|
||||
verbose_name = "Photo Submission"
|
||||
verbose_name_plural = "Photo Submissions"
|
||||
# Timestamps
|
||||
created_at = models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
db_index=True,
|
||||
help_text="When this action was performed",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
verbose_name = "Moderation Audit Log"
|
||||
verbose_name_plural = "Moderation Audit Logs"
|
||||
indexes = [
|
||||
models.Index(fields=["content_type", "object_id"]),
|
||||
models.Index(fields=["status"]),
|
||||
models.Index(fields=["submission", "created_at"]),
|
||||
models.Index(fields=["moderator", "created_at"]),
|
||||
models.Index(fields=["action", "created_at"]),
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"Photo submission by {self.user.username} for {self.content_object}"
|
||||
|
||||
def claim(self, user: UserType) -> None:
|
||||
"""
|
||||
Claim this photo submission for review.
|
||||
Transition: PENDING -> CLAIMED
|
||||
|
||||
Args:
|
||||
user: The moderator claiming this submission
|
||||
|
||||
Raises:
|
||||
ValidationError: If submission is not in PENDING state
|
||||
"""
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
if self.status != "PENDING":
|
||||
raise ValidationError(f"Cannot claim submission: current status is {self.status}, expected PENDING")
|
||||
|
||||
# Set status directly (similar to unclaim method)
|
||||
# The transition_to_claimed FSM method was never defined
|
||||
self.status = "CLAIMED"
|
||||
self.claimed_by = user
|
||||
self.claimed_at = timezone.now()
|
||||
self.save()
|
||||
|
||||
def unclaim(self, user: UserType = None) -> None:
|
||||
"""
|
||||
Release claim on this photo submission.
|
||||
Transition: CLAIMED -> PENDING
|
||||
|
||||
Args:
|
||||
user: The user initiating the unclaim (for audit)
|
||||
|
||||
Raises:
|
||||
ValidationError: If submission is not in CLAIMED state
|
||||
"""
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
if self.status != "CLAIMED":
|
||||
raise ValidationError(f"Cannot unclaim submission: current status is {self.status}, expected CLAIMED")
|
||||
|
||||
# Set status directly (not via FSM transition to avoid cycle)
|
||||
# This is intentional - the unclaim action is a special "rollback" operation
|
||||
self.status = "PENDING"
|
||||
self.claimed_by = None
|
||||
self.claimed_at = None
|
||||
self.save()
|
||||
|
||||
def approve(self, moderator: UserType = None, notes: str = "", user=None) -> None:
|
||||
"""
|
||||
Approve the photo submission.
|
||||
Wrapper method that preserves business logic while using FSM.
|
||||
|
||||
Args:
|
||||
moderator: The user approving the submission
|
||||
notes: Optional approval notes
|
||||
user: Alternative parameter for FSM compatibility
|
||||
"""
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
from apps.parks.models.media import ParkPhoto
|
||||
from apps.rides.models.media import RidePhoto
|
||||
|
||||
# Use user parameter if provided (FSM convention)
|
||||
approver = user or moderator
|
||||
|
||||
# Validate state - must be CLAIMED before approval
|
||||
if self.status != "CLAIMED":
|
||||
raise ValidationError(
|
||||
f"Cannot approve photo submission: must be CLAIMED first (current status: {self.status})"
|
||||
)
|
||||
|
||||
# Determine the correct photo model based on the content type
|
||||
model_class = self.content_type.model_class()
|
||||
if model_class.__name__ == "Park":
|
||||
PhotoModel = ParkPhoto
|
||||
elif model_class.__name__ == "Ride":
|
||||
PhotoModel = RidePhoto
|
||||
else:
|
||||
raise ValueError(f"Unsupported content type: {model_class.__name__}")
|
||||
|
||||
# Create the approved photo
|
||||
PhotoModel.objects.create(
|
||||
uploaded_by=self.user,
|
||||
content_object=self.content_object,
|
||||
image=self.photo,
|
||||
caption=self.caption,
|
||||
is_approved=True,
|
||||
)
|
||||
|
||||
# Use FSM transition to update status
|
||||
self.transition_to_approved(user=approver)
|
||||
self.handled_by = approver # type: ignore
|
||||
self.handled_at = timezone.now()
|
||||
self.notes = notes
|
||||
self.save()
|
||||
|
||||
def reject(self, moderator: UserType = None, notes: str = "", user=None) -> None:
|
||||
"""
|
||||
Reject the photo submission.
|
||||
Wrapper method that preserves business logic while using FSM.
|
||||
|
||||
Args:
|
||||
moderator: The user rejecting the submission
|
||||
notes: Rejection reason
|
||||
user: Alternative parameter for FSM compatibility
|
||||
"""
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
# Use user parameter if provided (FSM convention)
|
||||
rejecter = user or moderator
|
||||
|
||||
# Validate state - must be CLAIMED before rejection
|
||||
if self.status != "CLAIMED":
|
||||
raise ValidationError(
|
||||
f"Cannot reject photo submission: must be CLAIMED first (current status: {self.status})"
|
||||
)
|
||||
|
||||
# Use FSM transition to update status
|
||||
self.transition_to_rejected(user=rejecter)
|
||||
self.handled_by = rejecter # type: ignore
|
||||
self.handled_at = timezone.now()
|
||||
self.notes = notes
|
||||
self.save()
|
||||
|
||||
def auto_approve(self) -> None:
|
||||
"""Auto-approve submissions from moderators."""
|
||||
# Get user role safely
|
||||
user_role = getattr(self.user, "role", None)
|
||||
|
||||
# If user is moderator or above, claim then approve
|
||||
if user_role in ["MODERATOR", "ADMIN", "SUPERUSER"]:
|
||||
self.claim(user=self.user)
|
||||
self.approve(self.user)
|
||||
|
||||
def escalate(self, moderator: UserType = None, notes: str = "", user=None) -> None:
|
||||
"""
|
||||
Escalate the photo submission to admin.
|
||||
Wrapper method that preserves business logic while using FSM.
|
||||
|
||||
Args:
|
||||
moderator: The user escalating the submission
|
||||
notes: Escalation reason
|
||||
user: Alternative parameter for FSM compatibility
|
||||
"""
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
# Use user parameter if provided (FSM convention)
|
||||
escalator = user or moderator
|
||||
|
||||
# Validate state - must be CLAIMED before escalation
|
||||
if self.status != "CLAIMED":
|
||||
raise ValidationError(
|
||||
f"Cannot escalate photo submission: must be CLAIMED first (current status: {self.status})"
|
||||
)
|
||||
|
||||
# Use FSM transition to update status
|
||||
self.transition_to_escalated(user=escalator)
|
||||
self.handled_by = escalator # type: ignore
|
||||
self.handled_at = timezone.now()
|
||||
self.notes = notes
|
||||
self.save()
|
||||
actor = self.moderator.username if self.moderator else "System"
|
||||
return f"{self.get_action_display()} by {actor} on {self.submission_id}"
|
||||
|
||||
@@ -23,7 +23,6 @@ from .models import (
|
||||
ModerationAction,
|
||||
ModerationQueue,
|
||||
ModerationReport,
|
||||
PhotoSubmission,
|
||||
)
|
||||
|
||||
User = get_user_model()
|
||||
@@ -76,6 +75,10 @@ class EditSubmissionSerializer(serializers.ModelSerializer):
|
||||
status_icon = serializers.SerializerMethodField()
|
||||
status_display = serializers.CharField(source="get_status_display", read_only=True)
|
||||
time_since_created = serializers.SerializerMethodField()
|
||||
|
||||
# Photo URL for frontend compatibility (Cloudflare Images)
|
||||
photo_url = serializers.SerializerMethodField()
|
||||
cloudflare_image_id = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = EditSubmission
|
||||
@@ -100,6 +103,12 @@ class EditSubmissionSerializer(serializers.ModelSerializer):
|
||||
"claimed_at",
|
||||
"created_at",
|
||||
"time_since_created",
|
||||
# Photo fields (used when submission_type="PHOTO")
|
||||
"photo",
|
||||
"photo_url", # Cloudflare image URL for frontend
|
||||
"cloudflare_image_id",
|
||||
"caption",
|
||||
"date_taken",
|
||||
]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
@@ -113,6 +122,8 @@ class EditSubmissionSerializer(serializers.ModelSerializer):
|
||||
"status_display",
|
||||
"content_type_name",
|
||||
"time_since_created",
|
||||
"photo_url",
|
||||
"cloudflare_image_id",
|
||||
]
|
||||
|
||||
def get_status_color(self, obj) -> str:
|
||||
@@ -151,6 +162,16 @@ class EditSubmissionSerializer(serializers.ModelSerializer):
|
||||
minutes = diff.seconds // 60
|
||||
return f"{minutes} minutes ago"
|
||||
|
||||
def get_photo_url(self, obj) -> str | None:
|
||||
"""Return Cloudflare image URL for photo submissions."""
|
||||
if obj.photo:
|
||||
return getattr(obj.photo, "image_url", None) or getattr(obj.photo, "url", None)
|
||||
return None
|
||||
|
||||
def get_cloudflare_image_id(self, obj) -> str | None:
|
||||
"""Expose Cloudflare image id for clients expecting Supabase-like fields."""
|
||||
return getattr(obj.photo, "id", None) if obj.photo else None
|
||||
|
||||
|
||||
class EditSubmissionListSerializer(serializers.ModelSerializer):
|
||||
"""Optimized serializer for EditSubmission lists."""
|
||||
@@ -208,6 +229,8 @@ class CreateEditSubmissionSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
|
||||
entity_type = serializers.CharField(write_only=True, help_text="Entity type: park, ride, company, ride_model")
|
||||
caption = serializers.CharField(required=False, allow_blank=True)
|
||||
date_taken = serializers.DateField(required=False, allow_null=True)
|
||||
|
||||
class Meta:
|
||||
model = EditSubmission
|
||||
@@ -216,10 +239,25 @@ class CreateEditSubmissionSerializer(serializers.ModelSerializer):
|
||||
"object_id",
|
||||
"submission_type",
|
||||
"changes",
|
||||
"photo",
|
||||
"caption",
|
||||
"date_taken",
|
||||
"reason",
|
||||
"source",
|
||||
]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
# Add photo field with lazy import to avoid app loading cycles
|
||||
from django_cloudflareimages_toolkit.models import CloudflareImage
|
||||
|
||||
self.fields["photo"] = serializers.PrimaryKeyRelatedField(
|
||||
queryset=CloudflareImage.objects.all(),
|
||||
required=False,
|
||||
allow_null=True,
|
||||
help_text="CloudflareImage id for photo submissions",
|
||||
)
|
||||
|
||||
def validate_entity_type(self, value):
|
||||
"""Convert entity_type string to ContentType."""
|
||||
entity_type_map = {
|
||||
@@ -242,16 +280,17 @@ class CreateEditSubmissionSerializer(serializers.ModelSerializer):
|
||||
|
||||
def validate_changes(self, value):
|
||||
"""Validate changes is a proper JSON object."""
|
||||
if value is None:
|
||||
return {}
|
||||
if not isinstance(value, dict):
|
||||
raise serializers.ValidationError("Changes must be a JSON object")
|
||||
if not value:
|
||||
raise serializers.ValidationError("Changes cannot be empty")
|
||||
return value
|
||||
|
||||
def validate(self, attrs):
|
||||
"""Cross-field validation."""
|
||||
submission_type = attrs.get("submission_type", "EDIT")
|
||||
object_id = attrs.get("object_id")
|
||||
changes = attrs.get("changes") or {}
|
||||
|
||||
# For EDIT submissions, object_id is required
|
||||
if submission_type == "EDIT" and not object_id:
|
||||
@@ -264,6 +303,16 @@ class CreateEditSubmissionSerializer(serializers.ModelSerializer):
|
||||
raise serializers.ValidationError(
|
||||
{"object_id": "object_id must be null for CREATE submissions"}
|
||||
)
|
||||
|
||||
# For PHOTO submissions, enforce required fields and allow empty changes
|
||||
if submission_type == "PHOTO":
|
||||
if not object_id:
|
||||
raise serializers.ValidationError({"object_id": "object_id is required for PHOTO submissions"})
|
||||
if not attrs.get("photo"):
|
||||
raise serializers.ValidationError({"photo": "photo is required for PHOTO submissions"})
|
||||
else:
|
||||
if not changes:
|
||||
raise serializers.ValidationError({"changes": "Changes cannot be empty"})
|
||||
|
||||
return attrs
|
||||
|
||||
@@ -294,6 +343,120 @@ class CreateEditSubmissionSerializer(serializers.ModelSerializer):
|
||||
return super().create(validated_data)
|
||||
|
||||
|
||||
class CreatePhotoSubmissionSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
Serializer for creating photo submissions with backward compatibility.
|
||||
|
||||
This is a specialized serializer for the /photos endpoint that:
|
||||
- Makes entity_type optional (can be inferred from content_type_id if provided)
|
||||
- Automatically sets submission_type to "PHOTO"
|
||||
- Allows empty changes (photos don't have field changes)
|
||||
|
||||
Supports both new format (entity_type) and legacy format (content_type_id + object_id).
|
||||
"""
|
||||
|
||||
entity_type = serializers.CharField(
|
||||
write_only=True,
|
||||
required=False, # Optional for backward compatibility
|
||||
allow_blank=True,
|
||||
help_text="Entity type: park, ride, company, ride_model (optional if content_type provided)"
|
||||
)
|
||||
content_type_id = serializers.IntegerField(
|
||||
write_only=True,
|
||||
required=False,
|
||||
help_text="Legacy: ContentType ID (alternative to entity_type)"
|
||||
)
|
||||
caption = serializers.CharField(required=False, allow_blank=True, default="")
|
||||
date_taken = serializers.DateField(required=False, allow_null=True)
|
||||
reason = serializers.CharField(required=False, allow_blank=True, default="Photo submission")
|
||||
|
||||
class Meta:
|
||||
model = EditSubmission
|
||||
fields = [
|
||||
"entity_type",
|
||||
"content_type_id",
|
||||
"object_id",
|
||||
"photo",
|
||||
"caption",
|
||||
"date_taken",
|
||||
"reason",
|
||||
]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
# Add photo field with lazy import to avoid app loading cycles
|
||||
from django_cloudflareimages_toolkit.models import CloudflareImage
|
||||
|
||||
self.fields["photo"] = serializers.PrimaryKeyRelatedField(
|
||||
queryset=CloudflareImage.objects.all(),
|
||||
required=True, # Photo is required for photo submissions
|
||||
help_text="CloudflareImage id for photo submissions",
|
||||
)
|
||||
|
||||
def validate(self, attrs):
|
||||
"""Validate and resolve content_type."""
|
||||
entity_type = attrs.get("entity_type")
|
||||
content_type_id = attrs.get("content_type_id")
|
||||
object_id = attrs.get("object_id")
|
||||
|
||||
# Must have object_id
|
||||
if not object_id:
|
||||
raise serializers.ValidationError({"object_id": "object_id is required for photo submissions"})
|
||||
|
||||
# Must have either entity_type or content_type_id
|
||||
if not entity_type and not content_type_id:
|
||||
raise serializers.ValidationError({
|
||||
"entity_type": "Either entity_type or content_type_id is required"
|
||||
})
|
||||
|
||||
return attrs
|
||||
|
||||
def create(self, validated_data):
|
||||
"""Create a photo submission."""
|
||||
entity_type = validated_data.pop("entity_type", None)
|
||||
content_type_id = validated_data.pop("content_type_id", None)
|
||||
|
||||
# Resolve ContentType
|
||||
if entity_type:
|
||||
# Map entity_type to ContentType
|
||||
entity_type_map = {
|
||||
"park": ("parks", "park"),
|
||||
"ride": ("rides", "ride"),
|
||||
"company": ("parks", "company"),
|
||||
"ride_model": ("rides", "ridemodel"),
|
||||
"manufacturer": ("parks", "company"),
|
||||
"designer": ("parks", "company"),
|
||||
"operator": ("parks", "company"),
|
||||
"property_owner": ("parks", "company"),
|
||||
}
|
||||
|
||||
entity_lower = entity_type.lower()
|
||||
if entity_lower not in entity_type_map:
|
||||
raise serializers.ValidationError({
|
||||
"entity_type": f"Invalid entity_type. Must be one of: {', '.join(entity_type_map.keys())}"
|
||||
})
|
||||
|
||||
app_label, model_name = entity_type_map[entity_lower]
|
||||
content_type = ContentType.objects.get(app_label=app_label, model=model_name)
|
||||
elif content_type_id:
|
||||
# Legacy: Use content_type_id directly
|
||||
try:
|
||||
content_type = ContentType.objects.get(pk=content_type_id)
|
||||
except ContentType.DoesNotExist:
|
||||
raise serializers.ValidationError({"content_type_id": "Invalid content_type_id"})
|
||||
else:
|
||||
raise serializers.ValidationError({"entity_type": "entity_type or content_type_id is required"})
|
||||
|
||||
# Set automatic fields for photo submission
|
||||
validated_data["user"] = self.context["request"].user
|
||||
validated_data["content_type"] = content_type
|
||||
validated_data["submission_type"] = "PHOTO"
|
||||
validated_data["changes"] = {} # Photos don't have field changes
|
||||
validated_data["status"] = "PENDING"
|
||||
|
||||
return super().create(validated_data)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Moderation Report Serializers
|
||||
# ============================================================================
|
||||
@@ -979,86 +1142,44 @@ class StateLogSerializer(serializers.ModelSerializer):
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
class PhotoSubmissionSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for PhotoSubmission."""
|
||||
|
||||
submitted_by = UserBasicSerializer(source="user", read_only=True)
|
||||
content_type_name = serializers.CharField(source="content_type.model", read_only=True)
|
||||
photo_url = serializers.SerializerMethodField()
|
||||
# ============================================================================
|
||||
# Moderation Audit Log Serializers
|
||||
# ============================================================================
|
||||
|
||||
# UI Metadata
|
||||
status_display = serializers.CharField(source="get_status_display", read_only=True)
|
||||
status_color = serializers.SerializerMethodField()
|
||||
status_icon = serializers.SerializerMethodField()
|
||||
time_since_created = serializers.SerializerMethodField()
|
||||
|
||||
class ModerationAuditLogSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for moderation audit logs."""
|
||||
|
||||
moderator = UserBasicSerializer(read_only=True)
|
||||
moderator_username = serializers.CharField(source="moderator.username", read_only=True, allow_null=True)
|
||||
submission_content_type = serializers.CharField(source="submission.content_type.model", read_only=True)
|
||||
action_display = serializers.CharField(source="get_action_display", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = PhotoSubmission
|
||||
from .models import ModerationAuditLog
|
||||
|
||||
model = ModerationAuditLog
|
||||
fields = [
|
||||
"id",
|
||||
"status",
|
||||
"status_display",
|
||||
"status_color",
|
||||
"status_icon",
|
||||
"content_type",
|
||||
"content_type_name",
|
||||
"object_id",
|
||||
"photo",
|
||||
"photo_url",
|
||||
"caption",
|
||||
"date_taken",
|
||||
"submitted_by",
|
||||
"handled_by",
|
||||
"handled_at",
|
||||
"submission",
|
||||
"submission_content_type",
|
||||
"moderator",
|
||||
"moderator_username",
|
||||
"action",
|
||||
"action_display",
|
||||
"previous_status",
|
||||
"new_status",
|
||||
"notes",
|
||||
"is_system_action",
|
||||
"is_test_data",
|
||||
"created_at",
|
||||
"time_since_created",
|
||||
]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"created_at",
|
||||
"submitted_by",
|
||||
"handled_by",
|
||||
"handled_at",
|
||||
"status_display",
|
||||
"status_color",
|
||||
"status_icon",
|
||||
"content_type_name",
|
||||
"photo_url",
|
||||
"time_since_created",
|
||||
"moderator",
|
||||
"moderator_username",
|
||||
"submission_content_type",
|
||||
"action_display",
|
||||
]
|
||||
|
||||
def get_photo_url(self, obj) -> str | None:
|
||||
if obj.photo:
|
||||
return obj.photo.image_url
|
||||
return None
|
||||
|
||||
def get_status_color(self, obj) -> str:
|
||||
colors = {
|
||||
"PENDING": "#f59e0b",
|
||||
"APPROVED": "#10b981",
|
||||
"REJECTED": "#ef4444",
|
||||
}
|
||||
return colors.get(obj.status, "#6b7280")
|
||||
|
||||
def get_status_icon(self, obj) -> str:
|
||||
icons = {
|
||||
"PENDING": "heroicons:clock",
|
||||
"APPROVED": "heroicons:check-circle",
|
||||
"REJECTED": "heroicons:x-circle",
|
||||
}
|
||||
return icons.get(obj.status, "heroicons:question-mark-circle")
|
||||
|
||||
def get_time_since_created(self, obj) -> str:
|
||||
"""Human-readable time since creation."""
|
||||
now = timezone.now()
|
||||
diff = now - obj.created_at
|
||||
|
||||
if diff.days > 0:
|
||||
return f"{diff.days} days ago"
|
||||
elif diff.seconds > 3600:
|
||||
hours = diff.seconds // 3600
|
||||
return f"{hours} hours ago"
|
||||
else:
|
||||
minutes = diff.seconds // 60
|
||||
return f"{minutes} minutes ago"
|
||||
|
||||
@@ -5,6 +5,7 @@ Following Django styleguide pattern for business logic encapsulation.
|
||||
|
||||
from typing import Any
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db import transaction
|
||||
from django.db.models import QuerySet
|
||||
from django.utils import timezone
|
||||
@@ -12,7 +13,7 @@ from django_fsm import TransitionNotAllowed
|
||||
|
||||
from apps.accounts.models import User
|
||||
|
||||
from .models import EditSubmission, ModerationQueue, PhotoSubmission
|
||||
from .models import EditSubmission, ModerationQueue
|
||||
|
||||
|
||||
class ModerationService:
|
||||
@@ -340,9 +341,13 @@ class ModerationService:
|
||||
Dictionary with submission info and queue status
|
||||
"""
|
||||
with transaction.atomic():
|
||||
# Create the photo submission
|
||||
submission = PhotoSubmission(
|
||||
content_object=content_object,
|
||||
# Create the photo submission using unified EditSubmission with PHOTO type
|
||||
submission = EditSubmission(
|
||||
content_type=ContentType.objects.get_for_model(content_object),
|
||||
object_id=content_object.pk,
|
||||
submission_type="PHOTO",
|
||||
changes={}, # Photos don't have field changes
|
||||
reason="Photo submission",
|
||||
photo=photo,
|
||||
caption=caption,
|
||||
date_taken=date_taken,
|
||||
@@ -439,9 +444,9 @@ class ModerationService:
|
||||
return queue_item
|
||||
|
||||
@staticmethod
|
||||
def _create_queue_item_for_photo_submission(*, submission: PhotoSubmission, submitter: User) -> ModerationQueue:
|
||||
def _create_queue_item_for_photo_submission(*, submission: EditSubmission, submitter: User) -> ModerationQueue:
|
||||
"""
|
||||
Create a moderation queue item for a photo submission.
|
||||
Create a moderation queue item for a photo submission (EditSubmission with type=PHOTO).
|
||||
|
||||
Args:
|
||||
submission: The photo submission
|
||||
@@ -582,8 +587,9 @@ class ModerationService:
|
||||
raise ValueError(f"Unknown action: {action}")
|
||||
|
||||
elif "photo_submission" in queue_item.tags:
|
||||
# Find PhotoSubmission
|
||||
submissions = PhotoSubmission.objects.filter(
|
||||
# Find PHOTO EditSubmission
|
||||
submissions = EditSubmission.objects.filter(
|
||||
submission_type="PHOTO",
|
||||
user=queue_item.flagged_by,
|
||||
content_type=queue_item.content_type,
|
||||
object_id=queue_item.entity_id,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
Signal handlers for moderation-related FSM state transitions.
|
||||
|
||||
This module provides signal handlers that execute when moderation
|
||||
models (EditSubmission, PhotoSubmission, ModerationReport, etc.)
|
||||
models (EditSubmission, ModerationReport, etc.)
|
||||
undergo state transitions.
|
||||
|
||||
Includes:
|
||||
@@ -114,6 +114,7 @@ def handle_submission_rejected(instance, source, target, user, context=None, **k
|
||||
Handle submission rejection transitions.
|
||||
|
||||
Called when an EditSubmission or PhotoSubmission is rejected.
|
||||
For photo submissions, queues Cloudflare image cleanup to prevent orphaned assets.
|
||||
|
||||
Args:
|
||||
instance: The submission instance.
|
||||
@@ -130,6 +131,19 @@ def handle_submission_rejected(instance, source, target, user, context=None, **k
|
||||
f"Submission {instance.pk} rejected by {user if user else 'system'}" f"{f': {reason}' if reason else ''}"
|
||||
)
|
||||
|
||||
# Cleanup Cloudflare image for rejected photo submissions
|
||||
if getattr(instance, "submission_type", None) == "PHOTO" and instance.photo:
|
||||
try:
|
||||
from apps.moderation.tasks import cleanup_cloudflare_image
|
||||
|
||||
# Get image ID from the CloudflareImage model
|
||||
image_id = getattr(instance.photo, "image_id", None) or str(instance.photo.id)
|
||||
if image_id:
|
||||
cleanup_cloudflare_image.delay(image_id)
|
||||
logger.info(f"Queued Cloudflare image cleanup for rejected submission {instance.pk}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to queue Cloudflare image cleanup for submission {instance.pk}: {e}")
|
||||
|
||||
|
||||
def handle_submission_escalated(instance, source, target, user, context=None, **kwargs):
|
||||
"""
|
||||
@@ -377,18 +391,13 @@ def register_moderation_signal_handlers():
|
||||
EditSubmission,
|
||||
ModerationQueue,
|
||||
ModerationReport,
|
||||
PhotoSubmission,
|
||||
)
|
||||
|
||||
# EditSubmission handlers
|
||||
# EditSubmission handlers (handles both EDIT and PHOTO types now)
|
||||
register_transition_handler(EditSubmission, "*", "APPROVED", handle_submission_approved, stage="post")
|
||||
register_transition_handler(EditSubmission, "*", "REJECTED", handle_submission_rejected, stage="post")
|
||||
register_transition_handler(EditSubmission, "*", "ESCALATED", handle_submission_escalated, stage="post")
|
||||
|
||||
# PhotoSubmission handlers
|
||||
register_transition_handler(PhotoSubmission, "*", "APPROVED", handle_submission_approved, stage="post")
|
||||
register_transition_handler(PhotoSubmission, "*", "REJECTED", handle_submission_rejected, stage="post")
|
||||
register_transition_handler(PhotoSubmission, "*", "ESCALATED", handle_submission_escalated, stage="post")
|
||||
|
||||
# ModerationReport handlers
|
||||
register_transition_handler(ModerationReport, "*", "RESOLVED", handle_report_resolved, stage="post")
|
||||
@@ -403,9 +412,6 @@ def register_moderation_signal_handlers():
|
||||
register_transition_handler(EditSubmission, "PENDING", "CLAIMED", handle_submission_claimed, stage="post")
|
||||
register_transition_handler(EditSubmission, "CLAIMED", "PENDING", handle_submission_unclaimed, stage="post")
|
||||
|
||||
# Claim/Unclaim handlers for PhotoSubmission
|
||||
register_transition_handler(PhotoSubmission, "PENDING", "CLAIMED", handle_submission_claimed, stage="post")
|
||||
register_transition_handler(PhotoSubmission, "CLAIMED", "PENDING", handle_submission_unclaimed, stage="post")
|
||||
|
||||
logger.info("Registered moderation signal handlers")
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ def expire_stale_claims(lock_duration_minutes: int = None) -> dict:
|
||||
Returns:
|
||||
dict: Summary with counts of processed, succeeded, and failed releases
|
||||
"""
|
||||
from apps.moderation.models import EditSubmission, PhotoSubmission
|
||||
from apps.moderation.models import EditSubmission
|
||||
|
||||
if lock_duration_minutes is None:
|
||||
lock_duration_minutes = DEFAULT_LOCK_DURATION_MINUTES
|
||||
@@ -52,7 +52,6 @@ def expire_stale_claims(lock_duration_minutes: int = None) -> dict:
|
||||
|
||||
result = {
|
||||
"edit_submissions": {"processed": 0, "released": 0, "failed": 0},
|
||||
"photo_submissions": {"processed": 0, "released": 0, "failed": 0},
|
||||
"failures": [],
|
||||
"cutoff_time": cutoff_time.isoformat(),
|
||||
}
|
||||
@@ -95,45 +94,45 @@ def expire_stale_claims(lock_duration_minutes: int = None) -> dict:
|
||||
source="task",
|
||||
)
|
||||
|
||||
# Process PhotoSubmissions with stale claims
|
||||
stale_photo_ids = list(
|
||||
PhotoSubmission.objects.filter(
|
||||
# Process EditSubmission with PHOTO type (unified model)
|
||||
stale_photo_edit_ids = list(
|
||||
EditSubmission.objects.filter(
|
||||
submission_type="PHOTO",
|
||||
status="CLAIMED",
|
||||
claimed_at__lt=cutoff_time,
|
||||
).values_list("id", flat=True)
|
||||
)
|
||||
|
||||
for submission_id in stale_photo_ids:
|
||||
result["photo_submissions"]["processed"] += 1
|
||||
for submission_id in stale_photo_edit_ids:
|
||||
result["edit_submissions"]["processed"] += 1 # Count with edit submissions
|
||||
try:
|
||||
with transaction.atomic():
|
||||
# Lock and fetch the specific row
|
||||
submission = PhotoSubmission.objects.select_for_update(skip_locked=True).filter(
|
||||
submission = EditSubmission.objects.select_for_update(skip_locked=True).filter(
|
||||
id=submission_id,
|
||||
status="CLAIMED", # Re-verify status in case it changed
|
||||
status="CLAIMED",
|
||||
).first()
|
||||
|
||||
if submission:
|
||||
_release_claim(submission)
|
||||
result["photo_submissions"]["released"] += 1
|
||||
result["edit_submissions"]["released"] += 1
|
||||
logger.info(
|
||||
"Released stale claim on PhotoSubmission %s (claimed by %s at %s)",
|
||||
"Released stale claim on PHOTO EditSubmission %s (claimed by %s at %s)",
|
||||
submission_id,
|
||||
submission.claimed_by,
|
||||
submission.claimed_at,
|
||||
)
|
||||
except Exception as e:
|
||||
result["photo_submissions"]["failed"] += 1
|
||||
error_msg = f"PhotoSubmission {submission_id}: {str(e)}"
|
||||
result["edit_submissions"]["failed"] += 1
|
||||
error_msg = f"PHOTO EditSubmission {submission_id}: {str(e)}"
|
||||
result["failures"].append(error_msg)
|
||||
capture_and_log(
|
||||
e,
|
||||
f"Release stale claim on PhotoSubmission {submission_id}",
|
||||
f"Release stale claim on PHOTO EditSubmission {submission_id}",
|
||||
source="task",
|
||||
)
|
||||
|
||||
total_released = result["edit_submissions"]["released"] + result["photo_submissions"]["released"]
|
||||
total_failed = result["edit_submissions"]["failed"] + result["photo_submissions"]["failed"]
|
||||
total_released = result["edit_submissions"]["released"]
|
||||
total_failed = result["edit_submissions"]["failed"]
|
||||
|
||||
logger.info(
|
||||
"Completed stale claims expiration: %s released, %s failed",
|
||||
@@ -152,7 +151,7 @@ def _release_claim(submission):
|
||||
and clear the claimed_by and claimed_at fields.
|
||||
|
||||
Args:
|
||||
submission: EditSubmission or PhotoSubmission instance
|
||||
submission: EditSubmission instance
|
||||
"""
|
||||
# Store info for logging before clearing
|
||||
claimed_by = submission.claimed_by
|
||||
@@ -168,3 +167,49 @@ def _release_claim(submission):
|
||||
claimed_by,
|
||||
claimed_at,
|
||||
)
|
||||
|
||||
|
||||
@shared_task(name="moderation.cleanup_cloudflare_image", bind=True, max_retries=3)
|
||||
def cleanup_cloudflare_image(self, image_id: str) -> dict:
|
||||
"""
|
||||
Delete an orphaned or rejected Cloudflare image.
|
||||
|
||||
This task is called when a photo submission is rejected to cleanup
|
||||
the associated Cloudflare image and prevent orphaned assets.
|
||||
|
||||
Args:
|
||||
image_id: The Cloudflare image ID to delete.
|
||||
|
||||
Returns:
|
||||
dict: Result with success status and message.
|
||||
"""
|
||||
from apps.core.utils.cloudflare import delete_cloudflare_image
|
||||
|
||||
logger.info("Cleaning up Cloudflare image: %s", image_id)
|
||||
|
||||
try:
|
||||
success = delete_cloudflare_image(image_id)
|
||||
|
||||
if success:
|
||||
return {
|
||||
"image_id": image_id,
|
||||
"success": True,
|
||||
"message": "Image deleted successfully",
|
||||
}
|
||||
else:
|
||||
# Retry on failure (may be transient API issue)
|
||||
raise Exception(f"Failed to delete Cloudflare image {image_id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("Cloudflare image cleanup failed: %s (attempt %d)", str(e), self.request.retries + 1)
|
||||
# Retry with exponential backoff
|
||||
try:
|
||||
self.retry(exc=e, countdown=60 * (2 ** self.request.retries))
|
||||
except self.MaxRetriesExceededError:
|
||||
logger.error("Max retries exceeded for Cloudflare image cleanup: %s", image_id)
|
||||
return {
|
||||
"image_id": image_id,
|
||||
"success": False,
|
||||
"message": f"Failed after {self.request.retries + 1} attempts: {str(e)}",
|
||||
}
|
||||
|
||||
|
||||
@@ -13,11 +13,10 @@ from django.test import RequestFactory, TestCase
|
||||
from apps.moderation.admin import (
|
||||
EditSubmissionAdmin,
|
||||
HistoryEventAdmin,
|
||||
PhotoSubmissionAdmin,
|
||||
StateLogAdmin,
|
||||
moderation_site,
|
||||
)
|
||||
from apps.moderation.models import EditSubmission, PhotoSubmission
|
||||
from apps.moderation.models import EditSubmission
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
@@ -101,32 +100,7 @@ class TestEditSubmissionAdmin(TestCase):
|
||||
assert "bulk_escalate" in actions
|
||||
|
||||
|
||||
class TestPhotoSubmissionAdmin(TestCase):
|
||||
"""Tests for PhotoSubmissionAdmin class."""
|
||||
|
||||
def setUp(self):
|
||||
self.factory = RequestFactory()
|
||||
self.site = AdminSite()
|
||||
self.admin = PhotoSubmissionAdmin(model=PhotoSubmission, admin_site=self.site)
|
||||
|
||||
def test_list_display_includes_preview(self):
|
||||
"""Verify photo preview is in list_display."""
|
||||
assert "photo_preview" in self.admin.list_display
|
||||
|
||||
def test_list_select_related(self):
|
||||
"""Verify select_related is configured."""
|
||||
assert "user" in self.admin.list_select_related
|
||||
assert "content_type" in self.admin.list_select_related
|
||||
assert "handled_by" in self.admin.list_select_related
|
||||
|
||||
def test_moderation_actions_registered(self):
|
||||
"""Verify moderation actions are registered."""
|
||||
request = self.factory.get("/admin/")
|
||||
request.user = User(is_superuser=True)
|
||||
|
||||
actions = self.admin.get_actions(request)
|
||||
assert "bulk_approve" in actions
|
||||
assert "bulk_reject" in actions
|
||||
# PhotoSubmissionAdmin tests removed - model consolidated into EditSubmission
|
||||
|
||||
|
||||
class TestStateLogAdmin(TestCase):
|
||||
@@ -200,9 +174,7 @@ class TestRegisteredModels(TestCase):
|
||||
"""Verify EditSubmission is registered with moderation site."""
|
||||
assert EditSubmission in moderation_site._registry
|
||||
|
||||
def test_photo_submission_registered(self):
|
||||
"""Verify PhotoSubmission is registered with moderation site."""
|
||||
assert PhotoSubmission in moderation_site._registry
|
||||
# PhotoSubmission registration test removed - model consolidated into EditSubmission
|
||||
|
||||
def test_state_log_registered(self):
|
||||
"""Verify StateLog is registered with moderation site."""
|
||||
|
||||
@@ -3,7 +3,7 @@ Comprehensive tests for the moderation app.
|
||||
|
||||
This module contains tests for:
|
||||
- EditSubmission state machine transitions
|
||||
- PhotoSubmission state machine transitions
|
||||
- EditSubmission with submission_type="PHOTO" (photo submissions)
|
||||
- ModerationReport state machine transitions
|
||||
- ModerationQueue state machine transitions
|
||||
- BulkOperation state machine transitions
|
||||
@@ -39,7 +39,6 @@ from ..models import (
|
||||
ModerationAction,
|
||||
ModerationQueue,
|
||||
ModerationReport,
|
||||
PhotoSubmission,
|
||||
)
|
||||
|
||||
User = get_user_model()
|
||||
@@ -1132,14 +1131,17 @@ class ModerationActionTests(TestCase):
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# PhotoSubmission FSM Transition Tests
|
||||
# EditSubmission PHOTO Type FSM Transition Tests
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class PhotoSubmissionTransitionTests(TestCase):
|
||||
"""Comprehensive tests for PhotoSubmission FSM transitions.
|
||||
class PhotoEditSubmissionTransitionTests(TestCase):
|
||||
"""Comprehensive tests for EditSubmission with submission_type='PHOTO' FSM transitions.
|
||||
|
||||
Note: All approve/reject/escalate transitions require CLAIMED state first.
|
||||
|
||||
These tests validate that photo submissions (using the unified EditSubmission model)
|
||||
have correct FSM behavior.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
@@ -1169,13 +1171,15 @@ class PhotoSubmissionTransitionTests(TestCase):
|
||||
)
|
||||
|
||||
def _create_submission(self, status="PENDING"):
|
||||
"""Helper to create a PhotoSubmission with proper CloudflareImage."""
|
||||
submission = PhotoSubmission.objects.create(
|
||||
"""Helper to create an EditSubmission with submission_type='PHOTO' and proper CloudflareImage."""
|
||||
submission = EditSubmission.objects.create(
|
||||
user=self.user,
|
||||
content_type=self.content_type,
|
||||
object_id=self.operator.id,
|
||||
submission_type="PHOTO", # Unified model
|
||||
photo=self.mock_image,
|
||||
caption="Test Photo",
|
||||
changes={}, # Photos use empty changes
|
||||
status="PENDING", # Always create as PENDING first
|
||||
)
|
||||
|
||||
|
||||
@@ -83,13 +83,17 @@ class SubmissionApprovalWorkflowTests(TestCase):
|
||||
|
||||
def test_photo_submission_approval_workflow(self):
|
||||
"""
|
||||
Test complete photo submission approval workflow.
|
||||
Test complete photo submission approval workflow using EditSubmission.
|
||||
|
||||
Flow: User submits photo → Moderator reviews → Moderator approves → Photo created
|
||||
|
||||
Note: Photos now use EditSubmission with submission_type="PHOTO" (unified model).
|
||||
"""
|
||||
from datetime import timedelta
|
||||
|
||||
from django_cloudflareimages_toolkit.models import CloudflareImage
|
||||
|
||||
from apps.moderation.models import PhotoSubmission
|
||||
from apps.moderation.models import EditSubmission
|
||||
from apps.parks.models import Company, Park
|
||||
|
||||
# Create target park
|
||||
@@ -105,18 +109,21 @@ class SubmissionApprovalWorkflowTests(TestCase):
|
||||
expires_at=timezone.now() + timedelta(days=365),
|
||||
)
|
||||
|
||||
# User submits a photo
|
||||
# User submits a photo using unified EditSubmission model
|
||||
content_type = ContentType.objects.get_for_model(park)
|
||||
submission = PhotoSubmission.objects.create(
|
||||
submission = EditSubmission.objects.create(
|
||||
user=self.regular_user,
|
||||
content_type=content_type,
|
||||
object_id=park.id,
|
||||
submission_type="PHOTO", # Unified model with PHOTO type
|
||||
status="PENDING",
|
||||
photo=mock_image,
|
||||
caption="Beautiful park entrance",
|
||||
changes={}, # Photos use empty changes dict
|
||||
)
|
||||
|
||||
self.assertEqual(submission.status, "PENDING")
|
||||
self.assertEqual(submission.submission_type, "PHOTO")
|
||||
|
||||
# Moderator claims the submission first (required FSM step)
|
||||
submission.claim(user=self.moderator)
|
||||
|
||||
@@ -45,23 +45,16 @@ class SubmissionListView(TemplateView):
|
||||
template_name = "moderation/partials/dashboard_content.html"
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
from itertools import chain
|
||||
|
||||
from .models import EditSubmission, PhotoSubmission
|
||||
from .models import EditSubmission
|
||||
|
||||
context = super().get_context_data(**kwargs)
|
||||
status = self.request.GET.get("status", "PENDING")
|
||||
|
||||
# Get filtered submissions
|
||||
# Get filtered submissions (EditSubmission now handles all types including PHOTO)
|
||||
edit_submissions = EditSubmission.objects.filter(status=status).select_related("user")
|
||||
photo_submissions = PhotoSubmission.objects.filter(status=status).select_related("user")
|
||||
|
||||
# Combine and sort
|
||||
context["submissions"] = sorted(
|
||||
chain(edit_submissions, photo_submissions),
|
||||
key=lambda x: x.created_at,
|
||||
reverse=True,
|
||||
)
|
||||
# Sort by created_at descending
|
||||
context["submissions"] = edit_submissions.order_by("-created_at")
|
||||
return context
|
||||
|
||||
|
||||
@@ -78,10 +71,10 @@ router.register(r"queue", ModerationQueueViewSet, basename="moderation-queue")
|
||||
router.register(r"actions", ModerationActionViewSet, basename="moderation-actions")
|
||||
router.register(r"bulk-operations", BulkOperationViewSet, basename="bulk-operations")
|
||||
router.register(r"users", UserModerationViewSet, basename="user-moderation")
|
||||
# EditSubmission - register under both names for compatibility
|
||||
# EditSubmission - handles all submission types (EDIT, CREATE, PHOTO)
|
||||
router.register(r"submissions", EditSubmissionViewSet, basename="submissions")
|
||||
router.register(r"edit-submissions", EditSubmissionViewSet, basename="edit-submissions")
|
||||
# PhotoSubmission - register under both names for compatibility
|
||||
# PhotoSubmissionViewSet - now queries EditSubmission with type=PHOTO, kept for API compatibility
|
||||
router.register(r"photos", PhotoSubmissionViewSet, basename="photos")
|
||||
router.register(r"photo-submissions", PhotoSubmissionViewSet, basename="photo-submissions")
|
||||
|
||||
@@ -98,12 +91,12 @@ fsm_transition_patterns = [
|
||||
{"app_label": "moderation", "model_name": "editsubmission"},
|
||||
name="submission_transition",
|
||||
),
|
||||
# PhotoSubmission transitions
|
||||
# PhotoSubmission transitions (now use editsubmission model since photos are EditSubmission with type=PHOTO)
|
||||
# URL: /api/moderation/photos/<pk>/transition/<transition_name>/
|
||||
path(
|
||||
"photos/<int:pk>/transition/<str:transition_name>/",
|
||||
FSMTransitionView.as_view(),
|
||||
{"app_label": "moderation", "model_name": "photosubmission"},
|
||||
{"app_label": "moderation", "model_name": "editsubmission"},
|
||||
name="photo_transition",
|
||||
),
|
||||
# ModerationReport transitions
|
||||
@@ -150,23 +143,23 @@ fsm_transition_patterns = [
|
||||
{"app_label": "moderation", "model_name": "editsubmission", "transition_name": "transition_to_escalated"},
|
||||
name="escalate_submission",
|
||||
),
|
||||
# Backward compatibility aliases for PhotoSubmission actions
|
||||
# Photo transition aliases (use editsubmission model since photos are EditSubmission with type=PHOTO)
|
||||
path(
|
||||
"photos/<int:pk>/approve/",
|
||||
FSMTransitionView.as_view(),
|
||||
{"app_label": "moderation", "model_name": "photosubmission", "transition_name": "transition_to_approved"},
|
||||
{"app_label": "moderation", "model_name": "editsubmission", "transition_name": "transition_to_approved"},
|
||||
name="approve_photo",
|
||||
),
|
||||
path(
|
||||
"photos/<int:pk>/reject/",
|
||||
FSMTransitionView.as_view(),
|
||||
{"app_label": "moderation", "model_name": "photosubmission", "transition_name": "transition_to_rejected"},
|
||||
{"app_label": "moderation", "model_name": "editsubmission", "transition_name": "transition_to_rejected"},
|
||||
name="reject_photo",
|
||||
),
|
||||
path(
|
||||
"photos/<int:pk>/escalate/",
|
||||
FSMTransitionView.as_view(),
|
||||
{"app_label": "moderation", "model_name": "photosubmission", "transition_name": "transition_to_escalated"},
|
||||
{"app_label": "moderation", "model_name": "editsubmission", "transition_name": "transition_to_escalated"},
|
||||
name="escalate_photo",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -20,11 +20,13 @@ from django.shortcuts import render
|
||||
from django.utils import timezone
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from django_fsm import TransitionNotAllowed, can_proceed
|
||||
from rest_framework import permissions, status, viewsets
|
||||
from rest_framework import permissions, serializers as drf_serializers, status, viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.filters import OrderingFilter, SearchFilter
|
||||
from rest_framework.response import Response
|
||||
|
||||
from drf_spectacular.utils import OpenApiResponse, extend_schema, inline_serializer
|
||||
|
||||
from apps.core.logging import log_business_event
|
||||
from apps.core.state_machine.exceptions import (
|
||||
TransitionPermissionDenied,
|
||||
@@ -44,7 +46,6 @@ from .models import (
|
||||
ModerationAction,
|
||||
ModerationQueue,
|
||||
ModerationReport,
|
||||
PhotoSubmission,
|
||||
)
|
||||
from .permissions import (
|
||||
CanViewModerationData,
|
||||
@@ -59,12 +60,12 @@ from .serializers import (
|
||||
CreateEditSubmissionSerializer,
|
||||
CreateModerationActionSerializer,
|
||||
CreateModerationReportSerializer,
|
||||
CreatePhotoSubmissionSerializer,
|
||||
EditSubmissionListSerializer,
|
||||
EditSubmissionSerializer,
|
||||
ModerationActionSerializer,
|
||||
ModerationQueueSerializer,
|
||||
ModerationReportSerializer,
|
||||
PhotoSubmissionSerializer,
|
||||
UpdateModerationReportSerializer,
|
||||
UserModerationProfileSerializer,
|
||||
)
|
||||
@@ -1566,6 +1567,30 @@ class EditSubmissionViewSet(viewsets.ModelViewSet):
|
||||
|
||||
return Response({"items": [item]})
|
||||
|
||||
@extend_schema(
|
||||
summary="Claim a submission for review",
|
||||
description="Claim a submission for review with concurrency protection using database row locking. "
|
||||
"Prevents race conditions when multiple moderators try to claim the same submission.",
|
||||
request=None,
|
||||
responses={
|
||||
200: inline_serializer(
|
||||
name="ClaimSuccessResponse",
|
||||
fields={
|
||||
"success": drf_serializers.BooleanField(),
|
||||
"locked_until": drf_serializers.DateTimeField(),
|
||||
"submission_id": drf_serializers.CharField(),
|
||||
"claimed_by": drf_serializers.CharField(),
|
||||
"claimed_at": drf_serializers.DateTimeField(allow_null=True),
|
||||
"status": drf_serializers.CharField(),
|
||||
"lock_duration_minutes": drf_serializers.IntegerField(),
|
||||
},
|
||||
),
|
||||
404: OpenApiResponse(description="Submission not found"),
|
||||
409: OpenApiResponse(description="Submission already claimed or being claimed by another moderator"),
|
||||
400: OpenApiResponse(description="Invalid state for claiming (not PENDING)"),
|
||||
},
|
||||
tags=["Moderation"],
|
||||
)
|
||||
@action(detail=True, methods=["post"], permission_classes=[IsModeratorOrAdmin])
|
||||
def claim(self, request, pk=None):
|
||||
"""
|
||||
@@ -1646,6 +1671,18 @@ class EditSubmissionViewSet(viewsets.ModelViewSet):
|
||||
except ValidationError as e:
|
||||
return Response({"success": False, "error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@extend_schema(
|
||||
summary="Release claim on a submission",
|
||||
description="Release the current user's claim on a submission. "
|
||||
"Only the claiming moderator or an admin can unclaim.",
|
||||
request=None,
|
||||
responses={
|
||||
200: EditSubmissionSerializer,
|
||||
403: OpenApiResponse(description="Only the claiming moderator or admin can unclaim"),
|
||||
400: OpenApiResponse(description="Submission is not claimed"),
|
||||
},
|
||||
tags=["Moderation"],
|
||||
)
|
||||
@action(detail=True, methods=["post"], permission_classes=[IsModeratorOrAdmin])
|
||||
def unclaim(self, request, pk=None):
|
||||
"""
|
||||
@@ -1683,6 +1720,17 @@ class EditSubmissionViewSet(viewsets.ModelViewSet):
|
||||
except ValidationError as e:
|
||||
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@extend_schema(
|
||||
summary="Approve a submission",
|
||||
description="Approve an edit submission and apply the proposed changes. "
|
||||
"Only moderators and admins can approve submissions.",
|
||||
request=None,
|
||||
responses={
|
||||
200: EditSubmissionSerializer,
|
||||
400: OpenApiResponse(description="Approval failed due to validation error"),
|
||||
},
|
||||
tags=["Moderation"],
|
||||
)
|
||||
@action(detail=True, methods=["post"], permission_classes=[IsModeratorOrAdmin])
|
||||
def approve(self, request, pk=None):
|
||||
submission = self.get_object()
|
||||
@@ -1694,6 +1742,20 @@ class EditSubmissionViewSet(viewsets.ModelViewSet):
|
||||
except Exception as e:
|
||||
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@extend_schema(
|
||||
summary="Reject a submission",
|
||||
description="Reject an edit submission with an optional reason. "
|
||||
"The submitter will be notified of the rejection.",
|
||||
request=inline_serializer(
|
||||
name="RejectSubmissionRequest",
|
||||
fields={"reason": drf_serializers.CharField(required=False, allow_blank=True)},
|
||||
),
|
||||
responses={
|
||||
200: EditSubmissionSerializer,
|
||||
400: OpenApiResponse(description="Rejection failed due to validation error"),
|
||||
},
|
||||
tags=["Moderation"],
|
||||
)
|
||||
@action(detail=True, methods=["post"], permission_classes=[IsModeratorOrAdmin])
|
||||
def reject(self, request, pk=None):
|
||||
submission = self.get_object()
|
||||
@@ -1706,6 +1768,20 @@ class EditSubmissionViewSet(viewsets.ModelViewSet):
|
||||
except Exception as e:
|
||||
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@extend_schema(
|
||||
summary="Escalate a submission",
|
||||
description="Escalate an edit submission to senior moderators or admins with a reason. "
|
||||
"Used for complex or controversial submissions requiring higher-level review.",
|
||||
request=inline_serializer(
|
||||
name="EscalateSubmissionRequest",
|
||||
fields={"reason": drf_serializers.CharField(required=False, allow_blank=True)},
|
||||
),
|
||||
responses={
|
||||
200: EditSubmissionSerializer,
|
||||
400: OpenApiResponse(description="Escalation failed due to validation error"),
|
||||
},
|
||||
tags=["Moderation"],
|
||||
)
|
||||
@action(detail=True, methods=["post"], permission_classes=[IsModeratorOrAdmin])
|
||||
def escalate(self, request, pk=None):
|
||||
submission = self.get_object()
|
||||
@@ -2131,23 +2207,32 @@ class PhotoSubmissionViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
ViewSet for managing photo submissions.
|
||||
|
||||
Now queries EditSubmission with submission_type="PHOTO" for unified model.
|
||||
Includes claim/unclaim endpoints with concurrency protection using
|
||||
database row locking (select_for_update) to prevent race conditions.
|
||||
"""
|
||||
|
||||
queryset = PhotoSubmission.objects.all()
|
||||
serializer_class = PhotoSubmissionSerializer
|
||||
# Use EditSubmission filtered by PHOTO type instead of separate PhotoSubmission model
|
||||
queryset = EditSubmission.objects.filter(submission_type="PHOTO")
|
||||
serializer_class = EditSubmissionSerializer # Use unified serializer
|
||||
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
|
||||
search_fields = ["caption", "notes"]
|
||||
ordering_fields = ["created_at", "status"]
|
||||
ordering = ["-created_at"]
|
||||
permission_classes = [CanViewModerationData]
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.action == "list":
|
||||
return EditSubmissionListSerializer
|
||||
if self.action == "create":
|
||||
return CreatePhotoSubmissionSerializer # Use photo-specific serializer
|
||||
return EditSubmissionSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = super().get_queryset()
|
||||
status = self.request.query_params.get("status")
|
||||
if status:
|
||||
queryset = queryset.filter(status=status)
|
||||
queryset = EditSubmission.objects.filter(submission_type="PHOTO")
|
||||
status_param = self.request.query_params.get("status")
|
||||
if status_param:
|
||||
queryset = queryset.filter(status=status_param)
|
||||
|
||||
# User filter
|
||||
user_id = self.request.query_params.get("user")
|
||||
@@ -2156,6 +2241,26 @@ class PhotoSubmissionViewSet(viewsets.ModelViewSet):
|
||||
|
||||
return queryset
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
"""
|
||||
Create a photo submission.
|
||||
|
||||
Backward-compatible: Uses CreatePhotoSubmissionSerializer for input
|
||||
validation which supports both new format (entity_type) and legacy
|
||||
format (content_type_id). Returns full submission data via EditSubmissionSerializer.
|
||||
"""
|
||||
# Use CreatePhotoSubmissionSerializer for input validation
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
self.perform_create(serializer)
|
||||
|
||||
# Return the created instance using EditSubmissionSerializer for full output
|
||||
# This includes id, status, timestamps, etc. that clients need
|
||||
instance = serializer.instance
|
||||
response_serializer = EditSubmissionSerializer(instance, context={"request": request})
|
||||
headers = self.get_success_headers(response_serializer.data)
|
||||
return Response(response_serializer.data, status=status.HTTP_201_CREATED, headers=headers)
|
||||
|
||||
@action(detail=True, methods=["post"], permission_classes=[IsModeratorOrAdmin])
|
||||
def claim(self, request, pk=None):
|
||||
"""
|
||||
@@ -2168,8 +2273,9 @@ class PhotoSubmissionViewSet(viewsets.ModelViewSet):
|
||||
|
||||
with transaction.atomic():
|
||||
try:
|
||||
submission = PhotoSubmission.objects.select_for_update(nowait=True).get(pk=pk)
|
||||
except PhotoSubmission.DoesNotExist:
|
||||
# Use EditSubmission filtered by PHOTO type
|
||||
submission = EditSubmission.objects.filter(submission_type="PHOTO").select_for_update(nowait=True).get(pk=pk)
|
||||
except EditSubmission.DoesNotExist:
|
||||
return Response({"error": "Submission not found"}, status=status.HTTP_404_NOT_FOUND)
|
||||
except DatabaseError:
|
||||
return Response(
|
||||
@@ -2198,9 +2304,10 @@ class PhotoSubmissionViewSet(viewsets.ModelViewSet):
|
||||
log_business_event(
|
||||
logger,
|
||||
event_type="submission_claimed",
|
||||
message=f"PhotoSubmission {submission.id} claimed by {request.user.username}",
|
||||
message=f"Photo EditSubmission {submission.id} claimed by {request.user.username}",
|
||||
context={
|
||||
"model": "PhotoSubmission",
|
||||
"model": "EditSubmission",
|
||||
"submission_type": "PHOTO",
|
||||
"object_id": submission.id,
|
||||
"claimed_by": request.user.username,
|
||||
},
|
||||
@@ -2246,7 +2353,7 @@ class PhotoSubmissionViewSet(viewsets.ModelViewSet):
|
||||
event_type="submission_unclaimed",
|
||||
message=f"PhotoSubmission {submission.id} unclaimed by {request.user.username}",
|
||||
context={
|
||||
"model": "PhotoSubmission",
|
||||
"model": "EditSubmission",
|
||||
"object_id": submission.id,
|
||||
"unclaimed_by": request.user.username,
|
||||
},
|
||||
@@ -2767,3 +2874,55 @@ class ModerationStatsView(APIView):
|
||||
}
|
||||
|
||||
return Response(stats_data)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Moderation Audit Log ViewSet
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class ModerationAuditLogViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
"""
|
||||
ViewSet for viewing moderation audit logs.
|
||||
|
||||
Provides read-only access to moderation action history for auditing
|
||||
and accountability purposes.
|
||||
"""
|
||||
|
||||
from .models import ModerationAuditLog
|
||||
from .serializers import ModerationAuditLogSerializer
|
||||
|
||||
queryset = ModerationAuditLog.objects.select_related(
|
||||
"submission", "submission__content_type", "moderator"
|
||||
).all()
|
||||
serializer_class = ModerationAuditLogSerializer
|
||||
permission_classes = [IsAdminOrSuperuser]
|
||||
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
|
||||
filterset_fields = ["action", "is_system_action", "is_test_data"]
|
||||
search_fields = ["notes"]
|
||||
ordering_fields = ["created_at", "action"]
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = super().get_queryset()
|
||||
|
||||
# Filter by submission ID
|
||||
submission_id = self.request.query_params.get("submission_id")
|
||||
if submission_id:
|
||||
queryset = queryset.filter(submission_id=submission_id)
|
||||
|
||||
# Filter by moderator ID
|
||||
moderator_id = self.request.query_params.get("moderator_id")
|
||||
if moderator_id:
|
||||
queryset = queryset.filter(moderator_id=moderator_id)
|
||||
|
||||
# Date range filtering
|
||||
start_date = self.request.query_params.get("start_date")
|
||||
end_date = self.request.query_params.get("end_date")
|
||||
|
||||
if start_date:
|
||||
queryset = queryset.filter(created_at__gte=start_date)
|
||||
if end_date:
|
||||
queryset = queryset.filter(created_at__lte=end_date)
|
||||
|
||||
return queryset
|
||||
|
||||
46
backend/apps/notifications/api/log_serializers.py
Normal file
46
backend/apps/notifications/api/log_serializers.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""
|
||||
Serializers for Notification Log API.
|
||||
"""
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
from apps.core.choices.serializers import RichChoiceSerializerField
|
||||
from apps.notifications.models import NotificationLog
|
||||
|
||||
|
||||
class NotificationLogSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for notification logs."""
|
||||
|
||||
status = RichChoiceSerializerField(
|
||||
choice_group="notification_log_statuses",
|
||||
domain="notifications",
|
||||
)
|
||||
user_username = serializers.CharField(
|
||||
source="user.username",
|
||||
read_only=True,
|
||||
allow_null=True,
|
||||
)
|
||||
user_email = serializers.EmailField(
|
||||
source="user.email",
|
||||
read_only=True,
|
||||
allow_null=True,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = NotificationLog
|
||||
fields = [
|
||||
"id",
|
||||
"user",
|
||||
"user_username",
|
||||
"user_email",
|
||||
"workflow_id",
|
||||
"notification_type",
|
||||
"channel",
|
||||
"status",
|
||||
"payload",
|
||||
"error_message",
|
||||
"novu_transaction_id",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
read_only_fields = ["id", "created_at", "updated_at", "user_username", "user_email"]
|
||||
61
backend/apps/notifications/api/log_views.py
Normal file
61
backend/apps/notifications/api/log_views.py
Normal file
@@ -0,0 +1,61 @@
|
||||
"""
|
||||
ViewSet for Notification Log API.
|
||||
"""
|
||||
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from drf_spectacular.utils import extend_schema, extend_schema_view
|
||||
from rest_framework import viewsets
|
||||
from rest_framework.filters import OrderingFilter, SearchFilter
|
||||
from rest_framework.permissions import IsAdminUser
|
||||
|
||||
from apps.notifications.models import NotificationLog
|
||||
|
||||
from .log_serializers import NotificationLogSerializer
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
list=extend_schema(
|
||||
summary="List notification logs",
|
||||
description="Get all notification logs with optional filtering by status, channel, or workflow.",
|
||||
tags=["Admin - Notifications"],
|
||||
),
|
||||
retrieve=extend_schema(
|
||||
summary="Get notification log",
|
||||
description="Get details of a specific notification log entry.",
|
||||
tags=["Admin - Notifications"],
|
||||
),
|
||||
)
|
||||
class NotificationLogViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
"""
|
||||
ViewSet for viewing notification logs.
|
||||
|
||||
Provides read-only access to notification delivery history.
|
||||
"""
|
||||
|
||||
queryset = NotificationLog.objects.select_related("user").all()
|
||||
serializer_class = NotificationLogSerializer
|
||||
permission_classes = [IsAdminUser]
|
||||
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
|
||||
filterset_fields = ["status", "channel", "workflow_id", "notification_type"]
|
||||
search_fields = ["workflow_id", "notification_type", "error_message"]
|
||||
ordering_fields = ["created_at", "status"]
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = super().get_queryset()
|
||||
|
||||
# Filter by user ID if provided
|
||||
user_id = self.request.query_params.get("user_id")
|
||||
if user_id:
|
||||
queryset = queryset.filter(user_id=user_id)
|
||||
|
||||
# Date range filtering
|
||||
start_date = self.request.query_params.get("start_date")
|
||||
end_date = self.request.query_params.get("end_date")
|
||||
|
||||
if start_date:
|
||||
queryset = queryset.filter(created_at__gte=start_date)
|
||||
if end_date:
|
||||
queryset = queryset.filter(created_at__lte=end_date)
|
||||
|
||||
return queryset
|
||||
185
backend/apps/notifications/choices.py
Normal file
185
backend/apps/notifications/choices.py
Normal file
@@ -0,0 +1,185 @@
|
||||
"""
|
||||
Rich Choice Objects for Notifications Domain
|
||||
|
||||
This module defines all choice objects for the notifications domain,
|
||||
using the RichChoices pattern for consistent UI rendering and validation.
|
||||
"""
|
||||
|
||||
from apps.core.choices import ChoiceCategory, RichChoice
|
||||
from apps.core.choices.registry import register_choices
|
||||
|
||||
# ============================================================================
|
||||
# Notification Log Status Choices
|
||||
# ============================================================================
|
||||
NOTIFICATION_LOG_STATUSES = [
|
||||
RichChoice(
|
||||
value="pending",
|
||||
label="Pending",
|
||||
description="Notification is queued for delivery",
|
||||
metadata={
|
||||
"color": "yellow",
|
||||
"icon": "clock",
|
||||
"css_class": "bg-yellow-100 text-yellow-800",
|
||||
"sort_order": 1,
|
||||
},
|
||||
category=ChoiceCategory.STATUS,
|
||||
),
|
||||
RichChoice(
|
||||
value="sent",
|
||||
label="Sent",
|
||||
description="Notification has been sent",
|
||||
metadata={
|
||||
"color": "blue",
|
||||
"icon": "send",
|
||||
"css_class": "bg-blue-100 text-blue-800",
|
||||
"sort_order": 2,
|
||||
},
|
||||
category=ChoiceCategory.STATUS,
|
||||
),
|
||||
RichChoice(
|
||||
value="delivered",
|
||||
label="Delivered",
|
||||
description="Notification was successfully delivered",
|
||||
metadata={
|
||||
"color": "green",
|
||||
"icon": "check-circle",
|
||||
"css_class": "bg-green-100 text-green-800",
|
||||
"sort_order": 3,
|
||||
},
|
||||
category=ChoiceCategory.STATUS,
|
||||
),
|
||||
RichChoice(
|
||||
value="failed",
|
||||
label="Failed",
|
||||
description="Notification delivery failed",
|
||||
metadata={
|
||||
"color": "red",
|
||||
"icon": "x-circle",
|
||||
"css_class": "bg-red-100 text-red-800",
|
||||
"sort_order": 4,
|
||||
},
|
||||
category=ChoiceCategory.STATUS,
|
||||
),
|
||||
]
|
||||
|
||||
# ============================================================================
|
||||
# System Announcement Severity Choices
|
||||
# ============================================================================
|
||||
ANNOUNCEMENT_SEVERITIES = [
|
||||
RichChoice(
|
||||
value="info",
|
||||
label="Information",
|
||||
description="Informational announcement",
|
||||
metadata={
|
||||
"color": "blue",
|
||||
"icon": "info",
|
||||
"css_class": "bg-blue-100 text-blue-800 border-blue-200",
|
||||
"sort_order": 1,
|
||||
},
|
||||
category=ChoiceCategory.PRIORITY,
|
||||
),
|
||||
RichChoice(
|
||||
value="warning",
|
||||
label="Warning",
|
||||
description="Warning announcement requiring attention",
|
||||
metadata={
|
||||
"color": "yellow",
|
||||
"icon": "alert-triangle",
|
||||
"css_class": "bg-yellow-100 text-yellow-800 border-yellow-200",
|
||||
"sort_order": 2,
|
||||
},
|
||||
category=ChoiceCategory.PRIORITY,
|
||||
),
|
||||
RichChoice(
|
||||
value="critical",
|
||||
label="Critical",
|
||||
description="Critical announcement requiring immediate attention",
|
||||
metadata={
|
||||
"color": "red",
|
||||
"icon": "alert-octagon",
|
||||
"css_class": "bg-red-100 text-red-800 border-red-200",
|
||||
"sort_order": 3,
|
||||
},
|
||||
category=ChoiceCategory.PRIORITY,
|
||||
),
|
||||
]
|
||||
|
||||
# ============================================================================
|
||||
# Notification Level Choices (for in-app notifications)
|
||||
# ============================================================================
|
||||
NOTIFICATION_LEVELS = [
|
||||
RichChoice(
|
||||
value="info",
|
||||
label="Info",
|
||||
description="Informational notification",
|
||||
metadata={
|
||||
"color": "blue",
|
||||
"icon": "info",
|
||||
"css_class": "bg-blue-100 text-blue-800",
|
||||
"sort_order": 1,
|
||||
},
|
||||
category=ChoiceCategory.NOTIFICATION,
|
||||
),
|
||||
RichChoice(
|
||||
value="success",
|
||||
label="Success",
|
||||
description="Success notification",
|
||||
metadata={
|
||||
"color": "green",
|
||||
"icon": "check-circle",
|
||||
"css_class": "bg-green-100 text-green-800",
|
||||
"sort_order": 2,
|
||||
},
|
||||
category=ChoiceCategory.NOTIFICATION,
|
||||
),
|
||||
RichChoice(
|
||||
value="warning",
|
||||
label="Warning",
|
||||
description="Warning notification",
|
||||
metadata={
|
||||
"color": "yellow",
|
||||
"icon": "alert-triangle",
|
||||
"css_class": "bg-yellow-100 text-yellow-800",
|
||||
"sort_order": 3,
|
||||
},
|
||||
category=ChoiceCategory.NOTIFICATION,
|
||||
),
|
||||
RichChoice(
|
||||
value="error",
|
||||
label="Error",
|
||||
description="Error notification",
|
||||
metadata={
|
||||
"color": "red",
|
||||
"icon": "x-circle",
|
||||
"css_class": "bg-red-100 text-red-800",
|
||||
"sort_order": 4,
|
||||
},
|
||||
category=ChoiceCategory.NOTIFICATION,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def register_notifications_choices() -> None:
|
||||
"""Register all notifications domain choices with the global registry."""
|
||||
register_choices(
|
||||
name="notification_log_statuses",
|
||||
choices=NOTIFICATION_LOG_STATUSES,
|
||||
domain="notifications",
|
||||
description="Status options for notification logs",
|
||||
)
|
||||
register_choices(
|
||||
name="announcement_severities",
|
||||
choices=ANNOUNCEMENT_SEVERITIES,
|
||||
domain="notifications",
|
||||
description="Severity levels for system announcements",
|
||||
)
|
||||
register_choices(
|
||||
name="notification_levels",
|
||||
choices=NOTIFICATION_LEVELS,
|
||||
domain="notifications",
|
||||
description="Level options for in-app notifications",
|
||||
)
|
||||
|
||||
|
||||
# Auto-register choices when module is imported
|
||||
register_notifications_choices()
|
||||
@@ -0,0 +1,50 @@
|
||||
# Generated by Django 5.2.10 on 2026-01-10 22:01
|
||||
|
||||
import apps.core.choices.fields
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("notifications", "0002_add_notification_model"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="notification",
|
||||
name="level",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
choice_group="notification_levels",
|
||||
choices=[("info", "Info"), ("success", "Success"), ("warning", "Warning"), ("error", "Error")],
|
||||
default="info",
|
||||
domain="notifications",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="notificationlog",
|
||||
name="status",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
choice_group="notification_log_statuses",
|
||||
choices=[("pending", "Pending"), ("sent", "Sent"), ("delivered", "Delivered"), ("failed", "Failed")],
|
||||
default="pending",
|
||||
domain="notifications",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="systemannouncement",
|
||||
name="severity",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
choice_group="announcement_severities",
|
||||
choices=[("info", "Information"), ("warning", "Warning"), ("critical", "Critical")],
|
||||
default="info",
|
||||
domain="notifications",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -14,6 +14,11 @@ Subscriber model is kept for backward compatibility but is optional.
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
|
||||
from apps.core.choices.fields import RichChoiceField
|
||||
|
||||
# Import choices to ensure registration on app load
|
||||
from . import choices # noqa: F401
|
||||
|
||||
|
||||
class Subscriber(models.Model):
|
||||
"""
|
||||
@@ -100,12 +105,6 @@ class NotificationLog(models.Model):
|
||||
Audit log of sent notifications.
|
||||
"""
|
||||
|
||||
class Status(models.TextChoices):
|
||||
PENDING = "pending", "Pending"
|
||||
SENT = "sent", "Sent"
|
||||
DELIVERED = "delivered", "Delivered"
|
||||
FAILED = "failed", "Failed"
|
||||
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.SET_NULL,
|
||||
@@ -115,10 +114,11 @@ class NotificationLog(models.Model):
|
||||
workflow_id = models.CharField(max_length=100, db_index=True)
|
||||
notification_type = models.CharField(max_length=50)
|
||||
channel = models.CharField(max_length=20) # email, push, in_app, sms
|
||||
status = models.CharField(
|
||||
status = RichChoiceField(
|
||||
choice_group="notification_log_statuses",
|
||||
domain="notifications",
|
||||
max_length=20,
|
||||
choices=Status.choices,
|
||||
default=Status.PENDING,
|
||||
default="pending",
|
||||
)
|
||||
payload = models.JSONField(default=dict, blank=True)
|
||||
error_message = models.TextField(blank=True)
|
||||
@@ -144,17 +144,13 @@ class SystemAnnouncement(models.Model):
|
||||
System-wide announcements.
|
||||
"""
|
||||
|
||||
class Severity(models.TextChoices):
|
||||
INFO = "info", "Information"
|
||||
WARNING = "warning", "Warning"
|
||||
CRITICAL = "critical", "Critical"
|
||||
|
||||
title = models.CharField(max_length=255)
|
||||
message = models.TextField()
|
||||
severity = models.CharField(
|
||||
severity = RichChoiceField(
|
||||
choice_group="announcement_severities",
|
||||
domain="notifications",
|
||||
max_length=20,
|
||||
choices=Severity.choices,
|
||||
default=Severity.INFO,
|
||||
default="info",
|
||||
)
|
||||
action_url = models.URLField(blank=True)
|
||||
is_active = models.BooleanField(default=True)
|
||||
@@ -184,12 +180,6 @@ class Notification(models.Model):
|
||||
supporting both in-app and email notification channels.
|
||||
"""
|
||||
|
||||
class Level(models.TextChoices):
|
||||
INFO = "info", "Info"
|
||||
SUCCESS = "success", "Success"
|
||||
WARNING = "warning", "Warning"
|
||||
ERROR = "error", "Error"
|
||||
|
||||
# Who receives the notification
|
||||
recipient = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
@@ -207,10 +197,11 @@ class Notification(models.Model):
|
||||
# What happened
|
||||
verb = models.CharField(max_length=255)
|
||||
description = models.TextField(blank=True)
|
||||
level = models.CharField(
|
||||
level = RichChoiceField(
|
||||
choice_group="notification_levels",
|
||||
domain="notifications",
|
||||
max_length=20,
|
||||
choices=Level.choices,
|
||||
default=Level.INFO,
|
||||
default="info",
|
||||
)
|
||||
# The object that was acted upon (generic foreign key)
|
||||
action_object_content_type = models.ForeignKey(
|
||||
|
||||
@@ -4,6 +4,8 @@ Notification serializers.
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
from apps.core.choices.serializers import RichChoiceSerializerField
|
||||
|
||||
from .models import NotificationLog, NotificationPreference, Subscriber, SystemAnnouncement
|
||||
|
||||
|
||||
@@ -131,8 +133,9 @@ class CreateAnnouncementSerializer(serializers.Serializer):
|
||||
|
||||
title = serializers.CharField(required=True, max_length=255)
|
||||
message = serializers.CharField(required=True)
|
||||
severity = serializers.ChoiceField(
|
||||
choices=["info", "warning", "critical"],
|
||||
severity = RichChoiceSerializerField(
|
||||
choice_group="announcement_severities",
|
||||
domain="notifications",
|
||||
default="info",
|
||||
)
|
||||
action_url = serializers.URLField(required=False, allow_blank=True)
|
||||
|
||||
@@ -238,6 +238,186 @@ PARKS_COMPANY_ROLES = [
|
||||
),
|
||||
]
|
||||
|
||||
# ============================================================================
|
||||
# Person/Entity Type Choices (for Company model)
|
||||
# ============================================================================
|
||||
PERSON_TYPES = [
|
||||
RichChoice(
|
||||
value="INDIVIDUAL",
|
||||
label="Individual",
|
||||
description="Single person or sole proprietor",
|
||||
metadata={"color": "blue", "icon": "user", "css_class": "bg-blue-100 text-blue-800", "sort_order": 1},
|
||||
category=ChoiceCategory.CLASSIFICATION,
|
||||
),
|
||||
RichChoice(
|
||||
value="FIRM",
|
||||
label="Firm",
|
||||
description="Professional services firm",
|
||||
metadata={"color": "indigo", "icon": "briefcase", "css_class": "bg-indigo-100 text-indigo-800", "sort_order": 2},
|
||||
category=ChoiceCategory.CLASSIFICATION,
|
||||
),
|
||||
RichChoice(
|
||||
value="ORGANIZATION",
|
||||
label="Organization",
|
||||
description="Non-profit or member organization",
|
||||
metadata={"color": "green", "icon": "users", "css_class": "bg-green-100 text-green-800", "sort_order": 3},
|
||||
category=ChoiceCategory.CLASSIFICATION,
|
||||
),
|
||||
RichChoice(
|
||||
value="CORPORATION",
|
||||
label="Corporation",
|
||||
description="Incorporated business entity",
|
||||
metadata={"color": "purple", "icon": "building", "css_class": "bg-purple-100 text-purple-800", "sort_order": 4},
|
||||
category=ChoiceCategory.CLASSIFICATION,
|
||||
),
|
||||
RichChoice(
|
||||
value="PARTNERSHIP",
|
||||
label="Partnership",
|
||||
description="Business partnership",
|
||||
metadata={"color": "orange", "icon": "handshake", "css_class": "bg-orange-100 text-orange-800", "sort_order": 5},
|
||||
category=ChoiceCategory.CLASSIFICATION,
|
||||
),
|
||||
RichChoice(
|
||||
value="GOVERNMENT",
|
||||
label="Government Entity",
|
||||
description="Government agency or public entity",
|
||||
metadata={"color": "gray", "icon": "landmark", "css_class": "bg-gray-100 text-gray-800", "sort_order": 6},
|
||||
category=ChoiceCategory.CLASSIFICATION,
|
||||
),
|
||||
]
|
||||
|
||||
# ============================================================================
|
||||
# Company Status Choices
|
||||
# ============================================================================
|
||||
COMPANY_STATUSES = [
|
||||
RichChoice(
|
||||
value="ACTIVE",
|
||||
label="Active",
|
||||
description="Company is currently operating",
|
||||
metadata={
|
||||
"color": "green",
|
||||
"icon": "check-circle",
|
||||
"css_class": "bg-green-100 text-green-800",
|
||||
"sort_order": 1,
|
||||
"is_active": True,
|
||||
},
|
||||
category=ChoiceCategory.STATUS,
|
||||
),
|
||||
RichChoice(
|
||||
value="DEFUNCT",
|
||||
label="Defunct",
|
||||
description="Company no longer exists",
|
||||
metadata={
|
||||
"color": "red",
|
||||
"icon": "x-circle",
|
||||
"css_class": "bg-red-100 text-red-800",
|
||||
"sort_order": 2,
|
||||
"is_active": False,
|
||||
},
|
||||
category=ChoiceCategory.STATUS,
|
||||
),
|
||||
RichChoice(
|
||||
value="MERGED",
|
||||
label="Merged",
|
||||
description="Company merged with another entity",
|
||||
metadata={
|
||||
"color": "purple",
|
||||
"icon": "git-merge",
|
||||
"css_class": "bg-purple-100 text-purple-800",
|
||||
"sort_order": 3,
|
||||
"is_active": False,
|
||||
},
|
||||
category=ChoiceCategory.STATUS,
|
||||
),
|
||||
RichChoice(
|
||||
value="ACQUIRED",
|
||||
label="Acquired",
|
||||
description="Company was acquired by another entity",
|
||||
metadata={
|
||||
"color": "blue",
|
||||
"icon": "arrow-right-circle",
|
||||
"css_class": "bg-blue-100 text-blue-800",
|
||||
"sort_order": 4,
|
||||
"is_active": False,
|
||||
},
|
||||
category=ChoiceCategory.STATUS,
|
||||
),
|
||||
RichChoice(
|
||||
value="RENAMED",
|
||||
label="Renamed",
|
||||
description="Company changed its name",
|
||||
metadata={
|
||||
"color": "yellow",
|
||||
"icon": "edit",
|
||||
"css_class": "bg-yellow-100 text-yellow-800",
|
||||
"sort_order": 5,
|
||||
"is_active": True,
|
||||
},
|
||||
category=ChoiceCategory.STATUS,
|
||||
),
|
||||
RichChoice(
|
||||
value="DORMANT",
|
||||
label="Dormant",
|
||||
description="Company is inactive but not dissolved",
|
||||
metadata={
|
||||
"color": "gray",
|
||||
"icon": "pause-circle",
|
||||
"css_class": "bg-gray-100 text-gray-800",
|
||||
"sort_order": 6,
|
||||
"is_active": False,
|
||||
},
|
||||
category=ChoiceCategory.STATUS,
|
||||
),
|
||||
]
|
||||
|
||||
# ============================================================================
|
||||
# Date Precision Choices (for parks domain - founding dates, opening dates, etc.)
|
||||
# ============================================================================
|
||||
DATE_PRECISION = [
|
||||
RichChoice(
|
||||
value="exact",
|
||||
label="Exact Date",
|
||||
description="Date is known exactly",
|
||||
metadata={"color": "green", "icon": "calendar", "sort_order": 1, "format": "YYYY-MM-DD"},
|
||||
category=ChoiceCategory.CLASSIFICATION,
|
||||
),
|
||||
RichChoice(
|
||||
value="month",
|
||||
label="Month and Year",
|
||||
description="Only month and year are known",
|
||||
metadata={"color": "blue", "icon": "calendar", "sort_order": 2, "format": "YYYY-MM"},
|
||||
category=ChoiceCategory.CLASSIFICATION,
|
||||
),
|
||||
RichChoice(
|
||||
value="year",
|
||||
label="Year Only",
|
||||
description="Only the year is known",
|
||||
metadata={"color": "yellow", "icon": "calendar", "sort_order": 3, "format": "YYYY"},
|
||||
category=ChoiceCategory.CLASSIFICATION,
|
||||
),
|
||||
RichChoice(
|
||||
value="decade",
|
||||
label="Decade",
|
||||
description="Only the decade is known",
|
||||
metadata={"color": "orange", "icon": "calendar", "sort_order": 4, "format": "YYYYs"},
|
||||
category=ChoiceCategory.CLASSIFICATION,
|
||||
),
|
||||
RichChoice(
|
||||
value="century",
|
||||
label="Century",
|
||||
description="Only the century is known",
|
||||
metadata={"color": "gray", "icon": "calendar", "sort_order": 5, "format": "YYc"},
|
||||
category=ChoiceCategory.CLASSIFICATION,
|
||||
),
|
||||
RichChoice(
|
||||
value="approximate",
|
||||
label="Approximate",
|
||||
description="Date is approximate/estimated",
|
||||
metadata={"color": "gray", "icon": "help-circle", "sort_order": 6, "format": "~YYYY"},
|
||||
category=ChoiceCategory.CLASSIFICATION,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def register_parks_choices():
|
||||
"""Register all parks domain choices with the global registry"""
|
||||
@@ -266,6 +446,31 @@ def register_parks_choices():
|
||||
metadata={"domain": "parks", "type": "company_role"},
|
||||
)
|
||||
|
||||
register_choices(
|
||||
name="person_types",
|
||||
choices=PERSON_TYPES,
|
||||
domain="parks",
|
||||
description="Person/entity type classifications",
|
||||
metadata={"domain": "parks", "type": "person_type"},
|
||||
)
|
||||
|
||||
register_choices(
|
||||
name="company_statuses",
|
||||
choices=COMPANY_STATUSES,
|
||||
domain="parks",
|
||||
description="Company operational status options",
|
||||
metadata={"domain": "parks", "type": "company_status"},
|
||||
)
|
||||
|
||||
register_choices(
|
||||
name="date_precision",
|
||||
choices=DATE_PRECISION,
|
||||
domain="parks",
|
||||
description="Date precision options for parks domain",
|
||||
metadata={"domain": "parks", "type": "date_precision"},
|
||||
)
|
||||
|
||||
|
||||
# Auto-register choices when module is imported
|
||||
register_parks_choices()
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ class ParkQuerySet(StatusQuerySet, ReviewableQuerySet, LocationQuerySet):
|
||||
"reviews",
|
||||
queryset=ParkReview.objects.select_related("user")
|
||||
.filter(is_published=True)
|
||||
.order_by("-created_at")[:10],
|
||||
.order_by("-created_at"),
|
||||
),
|
||||
"photos",
|
||||
)
|
||||
@@ -86,7 +86,8 @@ class ParkQuerySet(StatusQuerySet, ReviewableQuerySet, LocationQuerySet):
|
||||
|
||||
def for_map_display(self, *, bounds=None):
|
||||
"""Optimize for map display with minimal data."""
|
||||
queryset = self.select_related("operator").prefetch_related("location")
|
||||
# Use select_related for OneToOne relationship to enable values() access
|
||||
queryset = self.select_related("operator", "location")
|
||||
|
||||
if bounds:
|
||||
queryset = queryset.within_bounds(
|
||||
@@ -96,13 +97,15 @@ class ParkQuerySet(StatusQuerySet, ReviewableQuerySet, LocationQuerySet):
|
||||
west=bounds.west,
|
||||
)
|
||||
|
||||
# Note: location__latitude and location__longitude are @property
|
||||
# decorators, not actual DB fields. We access city/state/country which
|
||||
# are actual columns. For coordinates, callers should use the point field
|
||||
# or access the location object directly.
|
||||
return queryset.values(
|
||||
"id",
|
||||
"name",
|
||||
"slug",
|
||||
"status",
|
||||
"location__latitude",
|
||||
"location__longitude",
|
||||
"location__city",
|
||||
"location__state",
|
||||
"location__country",
|
||||
@@ -152,6 +155,10 @@ class ParkManager(StatusManager, ReviewableManager, LocationManager):
|
||||
"""Always prefetch location for park queries."""
|
||||
return self.get_queryset().with_location()
|
||||
|
||||
def search_autocomplete(self, *, query: str, limit: int = 10):
|
||||
"""Optimized search for autocomplete."""
|
||||
return self.get_queryset().search_autocomplete(query=query, limit=limit)
|
||||
|
||||
|
||||
class ParkAreaQuerySet(BaseQuerySet):
|
||||
"""QuerySet for ParkArea model."""
|
||||
@@ -284,25 +291,10 @@ class CompanyManager(BaseManager):
|
||||
def major_operators(self, *, min_parks: int = 5):
|
||||
return self.get_queryset().major_operators(min_parks=min_parks)
|
||||
|
||||
def manufacturers_with_ride_count(self):
|
||||
"""Get manufacturers with ride count annotation for list views."""
|
||||
return (
|
||||
self.get_queryset()
|
||||
.manufacturers()
|
||||
.annotate(ride_count=Count("manufactured_rides", distinct=True))
|
||||
.only("id", "name", "slug", "roles", "description")
|
||||
.order_by("name")
|
||||
)
|
||||
|
||||
def designers_with_ride_count(self):
|
||||
"""Get designers with ride count annotation for list views."""
|
||||
return (
|
||||
self.get_queryset()
|
||||
.filter(roles__contains=["DESIGNER"])
|
||||
.annotate(ride_count=Count("designed_rides", distinct=True))
|
||||
.only("id", "name", "slug", "roles", "description")
|
||||
.order_by("name")
|
||||
)
|
||||
# NOTE: manufacturers_with_ride_count and designers_with_ride_count were removed
|
||||
# because parks.Company doesn't have manufactured_rides/designed_rides relations.
|
||||
# Those relations exist on rides.Company, a separate model.
|
||||
# Use the rides app's company manager for ride-related company queries.
|
||||
|
||||
def operators_with_park_count(self):
|
||||
"""Get operators with park count annotation for list views."""
|
||||
|
||||
62
backend/apps/parks/migrations/0032_add_logo_image_id.py
Normal file
62
backend/apps/parks/migrations/0032_add_logo_image_id.py
Normal file
@@ -0,0 +1,62 @@
|
||||
# Generated by Django 5.2.10 on 2026-01-10 19:38
|
||||
|
||||
import pgtrigger.compiler
|
||||
import pgtrigger.migrations
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("parks", "0031_add_photographer_to_photos"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
pgtrigger.migrations.RemoveTrigger(
|
||||
model_name="company",
|
||||
name="insert_insert",
|
||||
),
|
||||
pgtrigger.migrations.RemoveTrigger(
|
||||
model_name="company",
|
||||
name="update_update",
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="company",
|
||||
name="logo_image_id",
|
||||
field=models.CharField(blank=True, help_text="Cloudflare image ID for logo image", max_length=255),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="companyevent",
|
||||
name="logo_image_id",
|
||||
field=models.CharField(blank=True, help_text="Cloudflare image ID for logo image", max_length=255),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="company",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="insert_insert",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
func='INSERT INTO "parks_companyevent" ("average_rating", "banner_image_id", "banner_image_url", "card_image_id", "card_image_url", "created_at", "description", "founded_date", "founded_date_precision", "founded_year", "headquarters_location", "id", "is_test_data", "location_id", "logo_image_id", "logo_url", "name", "parks_count", "person_type", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "review_count", "rides_count", "roles", "slug", "source_url", "status", "updated_at", "website") VALUES (NEW."average_rating", NEW."banner_image_id", NEW."banner_image_url", NEW."card_image_id", NEW."card_image_url", NEW."created_at", NEW."description", NEW."founded_date", NEW."founded_date_precision", NEW."founded_year", NEW."headquarters_location", NEW."id", NEW."is_test_data", NEW."location_id", NEW."logo_image_id", NEW."logo_url", NEW."name", NEW."parks_count", NEW."person_type", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."review_count", NEW."rides_count", NEW."roles", NEW."slug", NEW."source_url", NEW."status", NEW."updated_at", NEW."website"); RETURN NULL;',
|
||||
hash="cd95b20dc19fbc63d9ebb0bab67279ce3670cb2b",
|
||||
operation="INSERT",
|
||||
pgid="pgtrigger_insert_insert_35b57",
|
||||
table="parks_company",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="company",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="update_update",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
|
||||
func='INSERT INTO "parks_companyevent" ("average_rating", "banner_image_id", "banner_image_url", "card_image_id", "card_image_url", "created_at", "description", "founded_date", "founded_date_precision", "founded_year", "headquarters_location", "id", "is_test_data", "location_id", "logo_image_id", "logo_url", "name", "parks_count", "person_type", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "review_count", "rides_count", "roles", "slug", "source_url", "status", "updated_at", "website") VALUES (NEW."average_rating", NEW."banner_image_id", NEW."banner_image_url", NEW."card_image_id", NEW."card_image_url", NEW."created_at", NEW."description", NEW."founded_date", NEW."founded_date_precision", NEW."founded_year", NEW."headquarters_location", NEW."id", NEW."is_test_data", NEW."location_id", NEW."logo_image_id", NEW."logo_url", NEW."name", NEW."parks_count", NEW."person_type", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."review_count", NEW."rides_count", NEW."roles", NEW."slug", NEW."source_url", NEW."status", NEW."updated_at", NEW."website"); RETURN NULL;',
|
||||
hash="c1fcc2920ab586cb06bec0624e50d2dab6bcb113",
|
||||
operation="UPDATE",
|
||||
pgid="pgtrigger_update_update_d3286",
|
||||
table="parks_company",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,218 @@
|
||||
# Generated by Django 5.2.10 on 2026-01-10 22:01
|
||||
|
||||
import apps.core.choices.fields
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("parks", "0032_add_logo_image_id"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="company",
|
||||
name="founded_date_precision",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
blank=True,
|
||||
choice_group="date_precision",
|
||||
choices=[
|
||||
("exact", "Exact Date"),
|
||||
("month", "Month and Year"),
|
||||
("year", "Year Only"),
|
||||
("decade", "Decade"),
|
||||
("century", "Century"),
|
||||
("approximate", "Approximate"),
|
||||
],
|
||||
domain="parks",
|
||||
help_text="Precision of the founding date",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="company",
|
||||
name="person_type",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
blank=True,
|
||||
choice_group="person_types",
|
||||
choices=[
|
||||
("INDIVIDUAL", "Individual"),
|
||||
("FIRM", "Firm"),
|
||||
("ORGANIZATION", "Organization"),
|
||||
("CORPORATION", "Corporation"),
|
||||
("PARTNERSHIP", "Partnership"),
|
||||
("GOVERNMENT", "Government Entity"),
|
||||
],
|
||||
domain="parks",
|
||||
help_text="Type of entity (individual, firm, organization, etc.)",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="company",
|
||||
name="status",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
choice_group="company_statuses",
|
||||
choices=[
|
||||
("ACTIVE", "Active"),
|
||||
("DEFUNCT", "Defunct"),
|
||||
("MERGED", "Merged"),
|
||||
("ACQUIRED", "Acquired"),
|
||||
("RENAMED", "Renamed"),
|
||||
("DORMANT", "Dormant"),
|
||||
],
|
||||
default="ACTIVE",
|
||||
domain="parks",
|
||||
help_text="Current operational status of the company",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="companyevent",
|
||||
name="founded_date_precision",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
blank=True,
|
||||
choice_group="date_precision",
|
||||
choices=[
|
||||
("exact", "Exact Date"),
|
||||
("month", "Month and Year"),
|
||||
("year", "Year Only"),
|
||||
("decade", "Decade"),
|
||||
("century", "Century"),
|
||||
("approximate", "Approximate"),
|
||||
],
|
||||
domain="parks",
|
||||
help_text="Precision of the founding date",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="companyevent",
|
||||
name="person_type",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
blank=True,
|
||||
choice_group="person_types",
|
||||
choices=[
|
||||
("INDIVIDUAL", "Individual"),
|
||||
("FIRM", "Firm"),
|
||||
("ORGANIZATION", "Organization"),
|
||||
("CORPORATION", "Corporation"),
|
||||
("PARTNERSHIP", "Partnership"),
|
||||
("GOVERNMENT", "Government Entity"),
|
||||
],
|
||||
domain="parks",
|
||||
help_text="Type of entity (individual, firm, organization, etc.)",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="companyevent",
|
||||
name="status",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
choice_group="company_statuses",
|
||||
choices=[
|
||||
("ACTIVE", "Active"),
|
||||
("DEFUNCT", "Defunct"),
|
||||
("MERGED", "Merged"),
|
||||
("ACQUIRED", "Acquired"),
|
||||
("RENAMED", "Renamed"),
|
||||
("DORMANT", "Dormant"),
|
||||
],
|
||||
default="ACTIVE",
|
||||
domain="parks",
|
||||
help_text="Current operational status of the company",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="park",
|
||||
name="closing_date_precision",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
blank=True,
|
||||
choice_group="date_precision",
|
||||
choices=[
|
||||
("exact", "Exact Date"),
|
||||
("month", "Month and Year"),
|
||||
("year", "Year Only"),
|
||||
("decade", "Decade"),
|
||||
("century", "Century"),
|
||||
("approximate", "Approximate"),
|
||||
],
|
||||
default="exact",
|
||||
domain="parks",
|
||||
help_text="Precision of the closing date",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="park",
|
||||
name="opening_date_precision",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
blank=True,
|
||||
choice_group="date_precision",
|
||||
choices=[
|
||||
("exact", "Exact Date"),
|
||||
("month", "Month and Year"),
|
||||
("year", "Year Only"),
|
||||
("decade", "Decade"),
|
||||
("century", "Century"),
|
||||
("approximate", "Approximate"),
|
||||
],
|
||||
default="exact",
|
||||
domain="parks",
|
||||
help_text="Precision of the opening date",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="parkevent",
|
||||
name="closing_date_precision",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
blank=True,
|
||||
choice_group="date_precision",
|
||||
choices=[
|
||||
("exact", "Exact Date"),
|
||||
("month", "Month and Year"),
|
||||
("year", "Year Only"),
|
||||
("decade", "Decade"),
|
||||
("century", "Century"),
|
||||
("approximate", "Approximate"),
|
||||
],
|
||||
default="exact",
|
||||
domain="parks",
|
||||
help_text="Precision of the closing date",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="parkevent",
|
||||
name="opening_date_precision",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
blank=True,
|
||||
choice_group="date_precision",
|
||||
choices=[
|
||||
("exact", "Exact Date"),
|
||||
("month", "Month and Year"),
|
||||
("year", "Year Only"),
|
||||
("decade", "Decade"),
|
||||
("century", "Century"),
|
||||
("approximate", "Approximate"),
|
||||
],
|
||||
default="exact",
|
||||
domain="parks",
|
||||
help_text="Precision of the opening date",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -26,34 +26,20 @@ class Company(TrackedModel):
|
||||
description = models.TextField(blank=True, help_text="Detailed company description")
|
||||
website = models.URLField(blank=True, help_text="Company website URL")
|
||||
|
||||
# Person/Entity Type (ported from legacy thrillwiki-87)
|
||||
PERSON_TYPES = [
|
||||
("INDIVIDUAL", "Individual"),
|
||||
("FIRM", "Firm"),
|
||||
("ORGANIZATION", "Organization"),
|
||||
("CORPORATION", "Corporation"),
|
||||
("PARTNERSHIP", "Partnership"),
|
||||
("GOVERNMENT", "Government Entity"),
|
||||
]
|
||||
person_type = models.CharField(
|
||||
# Person/Entity Type - using RichChoiceField
|
||||
person_type = RichChoiceField(
|
||||
choice_group="person_types",
|
||||
domain="parks",
|
||||
max_length=20,
|
||||
choices=PERSON_TYPES,
|
||||
blank=True,
|
||||
help_text="Type of entity (individual, firm, organization, etc.)",
|
||||
)
|
||||
|
||||
# Company Status (ported from legacy)
|
||||
COMPANY_STATUSES = [
|
||||
("ACTIVE", "Active"),
|
||||
("DEFUNCT", "Defunct"),
|
||||
("MERGED", "Merged"),
|
||||
("ACQUIRED", "Acquired"),
|
||||
("RENAMED", "Renamed"),
|
||||
("DORMANT", "Dormant"),
|
||||
]
|
||||
status = models.CharField(
|
||||
# Company Status - using RichChoiceField
|
||||
status = RichChoiceField(
|
||||
choice_group="company_statuses",
|
||||
domain="parks",
|
||||
max_length=20,
|
||||
choices=COMPANY_STATUSES,
|
||||
default="ACTIVE",
|
||||
help_text="Current operational status of the company",
|
||||
)
|
||||
@@ -61,17 +47,10 @@ class Company(TrackedModel):
|
||||
# Founding Information (enhanced from just founded_year)
|
||||
founded_year = models.PositiveIntegerField(blank=True, null=True, help_text="Year the company was founded")
|
||||
founded_date = models.DateField(blank=True, null=True, help_text="Full founding date if known")
|
||||
DATE_PRECISION_CHOICES = [
|
||||
("exact", "Exact Date"),
|
||||
("month", "Month and Year"),
|
||||
("year", "Year Only"),
|
||||
("decade", "Decade"),
|
||||
("century", "Century"),
|
||||
("approximate", "Approximate"),
|
||||
]
|
||||
founded_date_precision = models.CharField(
|
||||
founded_date_precision = RichChoiceField(
|
||||
choice_group="date_precision",
|
||||
domain="parks",
|
||||
max_length=20,
|
||||
choices=DATE_PRECISION_CHOICES,
|
||||
blank=True,
|
||||
help_text="Precision of the founding date",
|
||||
)
|
||||
@@ -82,6 +61,11 @@ class Company(TrackedModel):
|
||||
card_image_url = models.URLField(blank=True, help_text="Card/thumbnail image for listings")
|
||||
|
||||
# Image ID fields (for frontend submissions - Cloudflare image IDs)
|
||||
logo_image_id = models.CharField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
help_text="Cloudflare image ID for logo image",
|
||||
)
|
||||
banner_image_id = models.CharField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
|
||||
@@ -54,31 +54,19 @@ class Park(StateMachineMixin, TrackedModel):
|
||||
|
||||
# Details
|
||||
opening_date = models.DateField(null=True, blank=True, help_text="Opening date")
|
||||
opening_date_precision = models.CharField(
|
||||
opening_date_precision = RichChoiceField(
|
||||
choice_group="date_precision",
|
||||
domain="parks",
|
||||
max_length=20,
|
||||
choices=[
|
||||
("exact", "Exact Date"),
|
||||
("month", "Month and Year"),
|
||||
("year", "Year Only"),
|
||||
("decade", "Decade"),
|
||||
("century", "Century"),
|
||||
("approximate", "Approximate"),
|
||||
],
|
||||
default="exact",
|
||||
blank=True,
|
||||
help_text="Precision of the opening date",
|
||||
)
|
||||
closing_date = models.DateField(null=True, blank=True, help_text="Closing date")
|
||||
closing_date_precision = models.CharField(
|
||||
closing_date_precision = RichChoiceField(
|
||||
choice_group="date_precision",
|
||||
domain="parks",
|
||||
max_length=20,
|
||||
choices=[
|
||||
("exact", "Exact Date"),
|
||||
("month", "Month and Year"),
|
||||
("year", "Year Only"),
|
||||
("decade", "Decade"),
|
||||
("century", "Century"),
|
||||
("approximate", "Approximate"),
|
||||
],
|
||||
default="exact",
|
||||
blank=True,
|
||||
help_text="Precision of the closing date",
|
||||
|
||||
@@ -139,12 +139,13 @@ class CompanyQueryOptimizationTests(TestCase):
|
||||
self.assertIn("OPERATOR", company.roles)
|
||||
|
||||
def test_manufacturers_with_ride_count_includes_annotation(self):
|
||||
"""Verify manufacturers_with_ride_count adds ride_count annotation."""
|
||||
result = Company.objects.manufacturers_with_ride_count()
|
||||
if result.exists():
|
||||
first = result.first()
|
||||
# Should have ride_count attribute
|
||||
self.assertTrue(hasattr(first, "ride_count"))
|
||||
"""This test is skipped - method was removed from parks.CompanyManager.
|
||||
|
||||
parks.Company doesn't have manufactured_rides relation (that exists on
|
||||
rides.Company). Use rides app company queries for ride-related annotations.
|
||||
"""
|
||||
# Method removed - parks.Company is for operators/owners, not manufacturers
|
||||
pass
|
||||
|
||||
def test_operators_with_park_count_includes_annotation(self):
|
||||
"""Verify operators_with_park_count adds park count annotations."""
|
||||
|
||||
99
backend/apps/reviews/choices.py
Normal file
99
backend/apps/reviews/choices.py
Normal file
@@ -0,0 +1,99 @@
|
||||
"""
|
||||
Rich Choice Objects for Reviews Domain
|
||||
|
||||
This module defines all choice objects for the reviews domain,
|
||||
using the RichChoices pattern for consistent UI rendering and validation.
|
||||
"""
|
||||
|
||||
from apps.core.choices import ChoiceCategory, RichChoice
|
||||
from apps.core.choices.registry import register_choices
|
||||
|
||||
# ============================================================================
|
||||
# Rating Value Choices (1-5 stars)
|
||||
# ============================================================================
|
||||
RATING_VALUES = [
|
||||
RichChoice(
|
||||
value="1",
|
||||
label="1 Star",
|
||||
description="Poor rating",
|
||||
metadata={
|
||||
"color": "red",
|
||||
"icon": "star",
|
||||
"css_class": "bg-red-100 text-red-800",
|
||||
"sort_order": 1,
|
||||
"numeric_value": 1,
|
||||
"star_count": 1,
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION,
|
||||
),
|
||||
RichChoice(
|
||||
value="2",
|
||||
label="2 Stars",
|
||||
description="Below average rating",
|
||||
metadata={
|
||||
"color": "orange",
|
||||
"icon": "star",
|
||||
"css_class": "bg-orange-100 text-orange-800",
|
||||
"sort_order": 2,
|
||||
"numeric_value": 2,
|
||||
"star_count": 2,
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION,
|
||||
),
|
||||
RichChoice(
|
||||
value="3",
|
||||
label="3 Stars",
|
||||
description="Average rating",
|
||||
metadata={
|
||||
"color": "yellow",
|
||||
"icon": "star",
|
||||
"css_class": "bg-yellow-100 text-yellow-800",
|
||||
"sort_order": 3,
|
||||
"numeric_value": 3,
|
||||
"star_count": 3,
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION,
|
||||
),
|
||||
RichChoice(
|
||||
value="4",
|
||||
label="4 Stars",
|
||||
description="Good rating",
|
||||
metadata={
|
||||
"color": "lime",
|
||||
"icon": "star",
|
||||
"css_class": "bg-lime-100 text-lime-800",
|
||||
"sort_order": 4,
|
||||
"numeric_value": 4,
|
||||
"star_count": 4,
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION,
|
||||
),
|
||||
RichChoice(
|
||||
value="5",
|
||||
label="5 Stars",
|
||||
description="Excellent rating",
|
||||
metadata={
|
||||
"color": "green",
|
||||
"icon": "star",
|
||||
"css_class": "bg-green-100 text-green-800",
|
||||
"sort_order": 5,
|
||||
"numeric_value": 5,
|
||||
"star_count": 5,
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def register_reviews_choices() -> None:
|
||||
"""Register all reviews domain choices with the global registry."""
|
||||
register_choices(
|
||||
name="rating_values",
|
||||
choices=RATING_VALUES,
|
||||
domain="reviews",
|
||||
description="Rating values for reviews (1-5 stars)",
|
||||
)
|
||||
|
||||
|
||||
# Auto-register choices when module is imported
|
||||
register_reviews_choices()
|
||||
@@ -0,0 +1,66 @@
|
||||
# Generated by Django 5.2.10 on 2026-01-12 01:01
|
||||
|
||||
import apps.core.choices.fields
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("rides", "0039_add_photographer_to_photos"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="RideSubType",
|
||||
fields=[
|
||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"name",
|
||||
models.CharField(help_text="Name of the ride sub-type (e.g., 'Flying Coaster')", max_length=100),
|
||||
),
|
||||
(
|
||||
"category",
|
||||
apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
choice_group="categories",
|
||||
choices=[
|
||||
("RC", "Roller Coaster"),
|
||||
("DR", "Dark Ride"),
|
||||
("FR", "Flat Ride"),
|
||||
("WR", "Water Ride"),
|
||||
("TR", "Transport Ride"),
|
||||
("OT", "Other"),
|
||||
],
|
||||
domain="rides",
|
||||
help_text="Ride category this sub-type belongs to",
|
||||
max_length=2,
|
||||
),
|
||||
),
|
||||
("description", models.TextField(blank=True, help_text="Description of this ride sub-type")),
|
||||
(
|
||||
"created_by",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
help_text="User who created this sub-type",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="created_ride_sub_types",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Ride Sub-Type",
|
||||
"verbose_name_plural": "Ride Sub-Types",
|
||||
"ordering": ["category", "name"],
|
||||
"abstract": False,
|
||||
"unique_together": {("category", "name")},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -17,12 +17,14 @@ from .rankings import RankingSnapshot, RidePairComparison, RideRanking
|
||||
from .reviews import RideReview
|
||||
from .rides import Ride, RideModel, RollerCoasterStats
|
||||
from .stats import DarkRideStats, FlatRideStats, KiddieRideStats, TransportationStats, WaterRideStats
|
||||
from .sub_types import RideSubType
|
||||
|
||||
__all__ = [
|
||||
# Primary models
|
||||
"Ride",
|
||||
"RideModel",
|
||||
"RideNameHistory",
|
||||
"RideSubType",
|
||||
"RollerCoasterStats",
|
||||
"WaterRideStats",
|
||||
"DarkRideStats",
|
||||
|
||||
67
backend/apps/rides/models/sub_types.py
Normal file
67
backend/apps/rides/models/sub_types.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""
|
||||
RideSubType model for categorizing rides by sub-type within each category.
|
||||
|
||||
This model replaces the legacy Supabase ride_sub_types table,
|
||||
providing a lookup table for ride sub-types (e.g., 'Flying Coaster',
|
||||
'Inverted Coaster' for roller coasters).
|
||||
"""
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
|
||||
from apps.core.choices import RichChoiceField
|
||||
from apps.core.models import TrackedModel
|
||||
|
||||
|
||||
class RideSubType(TrackedModel):
|
||||
"""
|
||||
Lookup table for ride sub-types categorized by ride category.
|
||||
|
||||
Examples:
|
||||
- Roller Coaster: Flying Coaster, Inverted Coaster, Dive Coaster
|
||||
- Water Ride: Log Flume, River Rapids, Splash Battle
|
||||
- Dark Ride: Trackless, Omnimover, Boat Ride
|
||||
"""
|
||||
|
||||
name = models.CharField(
|
||||
max_length=100,
|
||||
help_text="Name of the ride sub-type (e.g., 'Flying Coaster')",
|
||||
)
|
||||
category = RichChoiceField(
|
||||
choice_group="categories",
|
||||
domain="rides",
|
||||
max_length=2,
|
||||
help_text="Ride category this sub-type belongs to",
|
||||
)
|
||||
description = models.TextField(
|
||||
blank=True,
|
||||
help_text="Description of this ride sub-type",
|
||||
)
|
||||
created_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="created_ride_sub_types",
|
||||
help_text="User who created this sub-type",
|
||||
)
|
||||
|
||||
class Meta(TrackedModel.Meta):
|
||||
verbose_name = "Ride Sub-Type"
|
||||
verbose_name_plural = "Ride Sub-Types"
|
||||
ordering = ["category", "name"]
|
||||
unique_together = [["category", "name"]]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.name} ({self.get_category_display()})"
|
||||
|
||||
def get_category_display(self) -> str:
|
||||
"""Get human-readable category label."""
|
||||
from apps.core.choices.registry import get_choice_group
|
||||
|
||||
group = get_choice_group("categories", domain="rides")
|
||||
if group:
|
||||
for choice in group.choices:
|
||||
if choice.value == self.category:
|
||||
return choice.label
|
||||
return self.category
|
||||
@@ -6,7 +6,7 @@ from django.utils import timezone
|
||||
|
||||
from apps.core.utils import capture_and_log
|
||||
|
||||
from .models import Ride
|
||||
from .models import Ride, RideSubType
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -286,3 +286,59 @@ def track_ride_name_changes(sender, instance, **kwargs):
|
||||
except Exception as e:
|
||||
logger.exception(f"Failed to track name change for ride {instance.pk}: {e}")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Auto-Create Ride Sub-Types on Ride Save
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@receiver(post_save, sender=Ride)
|
||||
def auto_create_ride_sub_type(sender, instance, created, **kwargs):
|
||||
"""
|
||||
Automatically create a RideSubType entry when a ride is saved with a new sub-type value.
|
||||
|
||||
This integrates with the submission pipeline - when a ride submission with a new
|
||||
ride_sub_type value is approved, the sub-type automatically gets added to the
|
||||
lookup table for future autocomplete suggestions.
|
||||
|
||||
Args:
|
||||
sender: The Ride model class.
|
||||
instance: The Ride instance that was saved.
|
||||
created: Whether this is a new ride (not used, we check sub-types for all saves).
|
||||
"""
|
||||
# Skip if no ride_sub_type is set
|
||||
if not instance.ride_sub_type or not instance.ride_sub_type.strip():
|
||||
return
|
||||
|
||||
# Skip if no category is set (can't categorize the sub-type)
|
||||
if not instance.category:
|
||||
return
|
||||
|
||||
ride_sub_type_value = instance.ride_sub_type.strip()
|
||||
|
||||
try:
|
||||
# Check if this sub-type already exists for this category
|
||||
existing = RideSubType.objects.filter(
|
||||
name__iexact=ride_sub_type_value,
|
||||
category=instance.category
|
||||
).exists()
|
||||
|
||||
if not existing:
|
||||
# Create the new sub-type entry
|
||||
RideSubType.objects.create(
|
||||
name=ride_sub_type_value,
|
||||
category=instance.category,
|
||||
description=f"Auto-created from ride: {instance.name}",
|
||||
created_by=getattr(instance, 'created_by', None),
|
||||
)
|
||||
logger.info(
|
||||
f"Auto-created RideSubType '{ride_sub_type_value}' for category "
|
||||
f"'{instance.category}' from ride '{instance.name}'"
|
||||
)
|
||||
except Exception as e:
|
||||
# Non-critical error - log but don't fail the ride save
|
||||
logger.warning(
|
||||
f"Failed to auto-create RideSubType for ride {instance.pk}: {e}"
|
||||
)
|
||||
|
||||
|
||||
|
||||
358
backend/apps/support/choices.py
Normal file
358
backend/apps/support/choices.py
Normal file
@@ -0,0 +1,358 @@
|
||||
"""
|
||||
Rich Choice Objects for Support Domain
|
||||
|
||||
This module defines all choice objects for the support domain,
|
||||
using the RichChoices pattern for consistent UI rendering and validation.
|
||||
|
||||
Note: Values are kept lowercase for backward compatibility with existing data.
|
||||
"""
|
||||
|
||||
from apps.core.choices import ChoiceCategory, RichChoice
|
||||
from apps.core.choices.registry import register_choices
|
||||
|
||||
# ============================================================================
|
||||
# Ticket Status Choices
|
||||
# ============================================================================
|
||||
|
||||
TICKET_STATUSES = [
|
||||
RichChoice(
|
||||
value="open",
|
||||
label="Open",
|
||||
description="Ticket is awaiting response",
|
||||
metadata={
|
||||
"color": "yellow",
|
||||
"icon": "inbox",
|
||||
"css_class": "bg-yellow-100 text-yellow-800 border-yellow-200",
|
||||
"sort_order": 1,
|
||||
"can_transition_to": ["in_progress", "closed"],
|
||||
"is_actionable": True,
|
||||
},
|
||||
category=ChoiceCategory.STATUS,
|
||||
),
|
||||
RichChoice(
|
||||
value="in_progress",
|
||||
label="In Progress",
|
||||
description="Ticket is being worked on",
|
||||
metadata={
|
||||
"color": "blue",
|
||||
"icon": "clock",
|
||||
"css_class": "bg-blue-100 text-blue-800 border-blue-200",
|
||||
"sort_order": 2,
|
||||
"can_transition_to": ["closed"],
|
||||
"is_actionable": True,
|
||||
},
|
||||
category=ChoiceCategory.STATUS,
|
||||
),
|
||||
RichChoice(
|
||||
value="closed",
|
||||
label="Closed",
|
||||
description="Ticket has been resolved",
|
||||
metadata={
|
||||
"color": "green",
|
||||
"icon": "check-circle",
|
||||
"css_class": "bg-green-100 text-green-800 border-green-200",
|
||||
"sort_order": 3,
|
||||
"can_transition_to": ["open"],
|
||||
"is_actionable": False,
|
||||
"is_final": True,
|
||||
},
|
||||
category=ChoiceCategory.STATUS,
|
||||
),
|
||||
]
|
||||
|
||||
# ============================================================================
|
||||
# Ticket Category Choices
|
||||
# ============================================================================
|
||||
|
||||
TICKET_CATEGORIES = [
|
||||
RichChoice(
|
||||
value="general",
|
||||
label="General Inquiry",
|
||||
description="General questions or feedback",
|
||||
metadata={
|
||||
"color": "gray",
|
||||
"icon": "chat-bubble-left",
|
||||
"css_class": "bg-gray-100 text-gray-800 border-gray-200",
|
||||
"sort_order": 1,
|
||||
"default_priority": "low",
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION,
|
||||
),
|
||||
RichChoice(
|
||||
value="bug",
|
||||
label="Bug Report",
|
||||
description="Report a bug or issue with the platform",
|
||||
metadata={
|
||||
"color": "red",
|
||||
"icon": "bug-ant",
|
||||
"css_class": "bg-red-100 text-red-800 border-red-200",
|
||||
"sort_order": 2,
|
||||
"default_priority": "high",
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION,
|
||||
),
|
||||
RichChoice(
|
||||
value="partnership",
|
||||
label="Partnership",
|
||||
description="Partnership or collaboration inquiries",
|
||||
metadata={
|
||||
"color": "purple",
|
||||
"icon": "handshake",
|
||||
"css_class": "bg-purple-100 text-purple-800 border-purple-200",
|
||||
"sort_order": 3,
|
||||
"default_priority": "medium",
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION,
|
||||
),
|
||||
RichChoice(
|
||||
value="press",
|
||||
label="Press/Media",
|
||||
description="Press inquiries and media requests",
|
||||
metadata={
|
||||
"color": "indigo",
|
||||
"icon": "newspaper",
|
||||
"css_class": "bg-indigo-100 text-indigo-800 border-indigo-200",
|
||||
"sort_order": 4,
|
||||
"default_priority": "medium",
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION,
|
||||
),
|
||||
RichChoice(
|
||||
value="data",
|
||||
label="Data Correction",
|
||||
description="Request corrections to park or ride data",
|
||||
metadata={
|
||||
"color": "orange",
|
||||
"icon": "pencil-square",
|
||||
"css_class": "bg-orange-100 text-orange-800 border-orange-200",
|
||||
"sort_order": 5,
|
||||
"default_priority": "medium",
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION,
|
||||
),
|
||||
RichChoice(
|
||||
value="account",
|
||||
label="Account Issue",
|
||||
description="Account-related problems or requests",
|
||||
metadata={
|
||||
"color": "cyan",
|
||||
"icon": "user-circle",
|
||||
"css_class": "bg-cyan-100 text-cyan-800 border-cyan-200",
|
||||
"sort_order": 6,
|
||||
"default_priority": "high",
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION,
|
||||
),
|
||||
]
|
||||
|
||||
# ============================================================================
|
||||
# Report Type Choices (for user-submitted reports about content issues)
|
||||
# ============================================================================
|
||||
REPORT_TYPES = [
|
||||
RichChoice(
|
||||
value="inaccurate",
|
||||
label="Inaccurate Information",
|
||||
description="Information is factually incorrect",
|
||||
metadata={
|
||||
"color": "orange",
|
||||
"icon": "alert-circle",
|
||||
"css_class": "bg-orange-100 text-orange-800 border-orange-200",
|
||||
"sort_order": 1,
|
||||
"requires_evidence": True,
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION,
|
||||
),
|
||||
RichChoice(
|
||||
value="inappropriate",
|
||||
label="Inappropriate Content",
|
||||
description="Content is offensive or inappropriate",
|
||||
metadata={
|
||||
"color": "red",
|
||||
"icon": "flag",
|
||||
"css_class": "bg-red-100 text-red-800 border-red-200",
|
||||
"sort_order": 2,
|
||||
"requires_evidence": False,
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION,
|
||||
),
|
||||
RichChoice(
|
||||
value="spam",
|
||||
label="Spam",
|
||||
description="Content is spam or promotional",
|
||||
metadata={
|
||||
"color": "purple",
|
||||
"icon": "mail-x",
|
||||
"css_class": "bg-purple-100 text-purple-800 border-purple-200",
|
||||
"sort_order": 3,
|
||||
"requires_evidence": False,
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION,
|
||||
),
|
||||
RichChoice(
|
||||
value="copyright",
|
||||
label="Copyright Violation",
|
||||
description="Content violates copyright",
|
||||
metadata={
|
||||
"color": "indigo",
|
||||
"icon": "shield-alert",
|
||||
"css_class": "bg-indigo-100 text-indigo-800 border-indigo-200",
|
||||
"sort_order": 4,
|
||||
"requires_evidence": True,
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION,
|
||||
),
|
||||
RichChoice(
|
||||
value="duplicate",
|
||||
label="Duplicate Content",
|
||||
description="Content duplicates existing entry",
|
||||
metadata={
|
||||
"color": "yellow",
|
||||
"icon": "copy",
|
||||
"css_class": "bg-yellow-100 text-yellow-800 border-yellow-200",
|
||||
"sort_order": 5,
|
||||
"requires_evidence": True,
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION,
|
||||
),
|
||||
RichChoice(
|
||||
value="other",
|
||||
label="Other",
|
||||
description="Other issue not covered above",
|
||||
metadata={
|
||||
"color": "gray",
|
||||
"icon": "help-circle",
|
||||
"css_class": "bg-gray-100 text-gray-800 border-gray-200",
|
||||
"sort_order": 6,
|
||||
"requires_evidence": False,
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION,
|
||||
),
|
||||
]
|
||||
|
||||
# ============================================================================
|
||||
# Report Status Choices
|
||||
# ============================================================================
|
||||
REPORT_STATUSES = [
|
||||
RichChoice(
|
||||
value="pending",
|
||||
label="Pending",
|
||||
description="Report is awaiting review",
|
||||
metadata={
|
||||
"color": "yellow",
|
||||
"icon": "clock",
|
||||
"css_class": "bg-yellow-100 text-yellow-800 border-yellow-200",
|
||||
"sort_order": 1,
|
||||
"is_active": True,
|
||||
},
|
||||
category=ChoiceCategory.STATUS,
|
||||
),
|
||||
RichChoice(
|
||||
value="investigating",
|
||||
label="Investigating",
|
||||
description="Report is being investigated",
|
||||
metadata={
|
||||
"color": "blue",
|
||||
"icon": "search",
|
||||
"css_class": "bg-blue-100 text-blue-800 border-blue-200",
|
||||
"sort_order": 2,
|
||||
"is_active": True,
|
||||
},
|
||||
category=ChoiceCategory.STATUS,
|
||||
),
|
||||
RichChoice(
|
||||
value="resolved",
|
||||
label="Resolved",
|
||||
description="Report has been resolved with action taken",
|
||||
metadata={
|
||||
"color": "green",
|
||||
"icon": "check-circle",
|
||||
"css_class": "bg-green-100 text-green-800 border-green-200",
|
||||
"sort_order": 3,
|
||||
"is_active": False,
|
||||
},
|
||||
category=ChoiceCategory.STATUS,
|
||||
),
|
||||
RichChoice(
|
||||
value="dismissed",
|
||||
label="Dismissed",
|
||||
description="Report was dismissed as invalid or duplicate",
|
||||
metadata={
|
||||
"color": "gray",
|
||||
"icon": "x-circle",
|
||||
"css_class": "bg-gray-100 text-gray-800 border-gray-200",
|
||||
"sort_order": 4,
|
||||
"is_active": False,
|
||||
},
|
||||
category=ChoiceCategory.STATUS,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Email Direction Choices
|
||||
# ============================================================================
|
||||
EMAIL_DIRECTIONS = [
|
||||
RichChoice(
|
||||
value="inbound",
|
||||
label="Inbound",
|
||||
description="Email received from user",
|
||||
metadata={
|
||||
"color": "blue",
|
||||
"icon": "arrow-down-left",
|
||||
"css_class": "bg-blue-100 text-blue-800 border-blue-200",
|
||||
"sort_order": 1,
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION,
|
||||
),
|
||||
RichChoice(
|
||||
value="outbound",
|
||||
label="Outbound",
|
||||
description="Email sent to user",
|
||||
metadata={
|
||||
"color": "green",
|
||||
"icon": "arrow-up-right",
|
||||
"css_class": "bg-green-100 text-green-800 border-green-200",
|
||||
"sort_order": 2,
|
||||
},
|
||||
category=ChoiceCategory.CLASSIFICATION,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def register_support_choices() -> None:
|
||||
"""Register all support domain choices with the global registry."""
|
||||
register_choices(
|
||||
"ticket_statuses",
|
||||
TICKET_STATUSES,
|
||||
domain="support",
|
||||
description="Status options for support tickets",
|
||||
)
|
||||
register_choices(
|
||||
"ticket_categories",
|
||||
TICKET_CATEGORIES,
|
||||
domain="support",
|
||||
description="Category options for support tickets",
|
||||
)
|
||||
register_choices(
|
||||
"report_types",
|
||||
REPORT_TYPES,
|
||||
domain="support",
|
||||
description="Type options for user-submitted reports",
|
||||
)
|
||||
register_choices(
|
||||
"report_statuses",
|
||||
REPORT_STATUSES,
|
||||
domain="support",
|
||||
description="Status options for user-submitted reports",
|
||||
)
|
||||
register_choices(
|
||||
"email_directions",
|
||||
EMAIL_DIRECTIONS,
|
||||
domain="support",
|
||||
description="Direction options for email threads",
|
||||
)
|
||||
|
||||
|
||||
# Auto-register choices when module is imported
|
||||
register_support_choices()
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
# Generated by Django 5.2.10 on 2026-01-10 19:33
|
||||
|
||||
import apps.core.choices.fields
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("support", "0003_add_incident_and_report_models"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="ticket",
|
||||
name="category",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
choice_group="ticket_categories",
|
||||
choices=[
|
||||
("general", "General Inquiry"),
|
||||
("bug", "Bug Report"),
|
||||
("partnership", "Partnership"),
|
||||
("press", "Press/Media"),
|
||||
("data", "Data Correction"),
|
||||
("account", "Account Issue"),
|
||||
],
|
||||
db_index=True,
|
||||
default="general",
|
||||
domain="support",
|
||||
help_text="Category of the ticket",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="ticket",
|
||||
name="status",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
choice_group="ticket_statuses",
|
||||
choices=[("open", "Open"), ("in_progress", "In Progress"), ("closed", "Closed")],
|
||||
db_index=True,
|
||||
default="open",
|
||||
domain="support",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,53 @@
|
||||
# Generated by Django 5.2.10 on 2026-01-10 22:01
|
||||
|
||||
import apps.core.choices.fields
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("support", "0004_alter_ticket_category_alter_ticket_status"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="report",
|
||||
name="report_type",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
choice_group="report_types",
|
||||
choices=[
|
||||
("inaccurate", "Inaccurate Information"),
|
||||
("inappropriate", "Inappropriate Content"),
|
||||
("spam", "Spam"),
|
||||
("copyright", "Copyright Violation"),
|
||||
("duplicate", "Duplicate Content"),
|
||||
("other", "Other"),
|
||||
],
|
||||
db_index=True,
|
||||
domain="support",
|
||||
help_text="Type of issue being reported",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="report",
|
||||
name="status",
|
||||
field=apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
choice_group="report_statuses",
|
||||
choices=[
|
||||
("pending", "Pending"),
|
||||
("investigating", "Investigating"),
|
||||
("resolved", "Resolved"),
|
||||
("dismissed", "Dismissed"),
|
||||
],
|
||||
db_index=True,
|
||||
default="pending",
|
||||
domain="support",
|
||||
help_text="Current status of the report",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,147 @@
|
||||
# Generated by Django 5.2.10 on 2026-01-11 20:42
|
||||
|
||||
import apps.core.choices.fields
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("support", "0005_alter_report_report_type_alter_report_status"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="EmailThread",
|
||||
fields=[
|
||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"message_id",
|
||||
models.CharField(blank=True, help_text="Email message ID for threading", max_length=255),
|
||||
),
|
||||
("from_email", models.EmailField(help_text="Sender email address", max_length=254)),
|
||||
("to_email", models.EmailField(help_text="Recipient email address", max_length=254)),
|
||||
("subject", models.CharField(max_length=255)),
|
||||
("body_text", models.TextField(help_text="Plain text email body")),
|
||||
(
|
||||
"direction",
|
||||
apps.core.choices.fields.RichChoiceField(
|
||||
allow_deprecated=False,
|
||||
choice_group="email_directions",
|
||||
choices=[("inbound", "Inbound"), ("outbound", "Outbound")],
|
||||
domain="support",
|
||||
help_text="Whether email is inbound or outbound",
|
||||
max_length=10,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Email Thread",
|
||||
"verbose_name_plural": "Email Threads",
|
||||
"ordering": ["created_at"],
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="ticket",
|
||||
name="admin_notes",
|
||||
field=models.TextField(blank=True, help_text="Internal notes for administrators"),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="ticket",
|
||||
name="archived_at",
|
||||
field=models.DateTimeField(blank=True, help_text="When the ticket was archived", null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="ticket",
|
||||
name="archived_by",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
help_text="Staff member who archived this ticket",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="archived_tickets",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="ticket",
|
||||
name="assigned_to",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
help_text="Staff member assigned to this ticket",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="assigned_tickets",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="ticket",
|
||||
name="name",
|
||||
field=models.CharField(
|
||||
blank=True, help_text="Name of the submitter (for anonymous tickets)", max_length=255
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="ticket",
|
||||
name="resolved_at",
|
||||
field=models.DateTimeField(blank=True, help_text="When the ticket was resolved", null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="ticket",
|
||||
name="resolved_by",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
help_text="Staff member who resolved this ticket",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="resolved_tickets",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="ticket",
|
||||
name="ticket_number",
|
||||
field=models.CharField(blank=True, help_text="Human-readable ticket number", max_length=20, unique=True),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="ticket",
|
||||
index=models.Index(fields=["status", "created_at"], name="support_tic_status_d0b46e_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="ticket",
|
||||
index=models.Index(fields=["ticket_number"], name="support_tic_ticket__d87f40_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="ticket",
|
||||
index=models.Index(fields=["archived_at"], name="support_tic_archive_8fe8c5_idx"),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="emailthread",
|
||||
name="sent_by",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
help_text="Staff member who sent this email (for outbound)",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="sent_email_threads",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="emailthread",
|
||||
name="ticket",
|
||||
field=models.ForeignKey(
|
||||
help_text="Associated support ticket",
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="email_threads",
|
||||
to="support.ticket",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,27 @@
|
||||
# Generated by Django 5.2.10 on 2026-01-11 21:19
|
||||
|
||||
import apps.core.state_machine.fields
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("support", "0006_add_ticket_admin_fields_and_email_threads"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="ticket",
|
||||
name="status",
|
||||
field=apps.core.state_machine.fields.RichFSMField(
|
||||
allow_deprecated=False,
|
||||
choice_group="ticket_statuses",
|
||||
choices=[("open", "Open"), ("in_progress", "In Progress"), ("closed", "Closed")],
|
||||
db_index=True,
|
||||
default="open",
|
||||
domain="support",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -1,35 +1,26 @@
|
||||
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):
|
||||
STATUS_OPEN = "open"
|
||||
STATUS_IN_PROGRESS = "in_progress"
|
||||
STATUS_CLOSED = "closed"
|
||||
class Ticket(StateMachineMixin, TrackedModel):
|
||||
"""
|
||||
Support ticket model with FSM-managed status transitions.
|
||||
|
||||
Status workflow:
|
||||
open -> in_progress -> closed
|
||||
-> open (reopen)
|
||||
"""
|
||||
|
||||
STATUS_CHOICES = [
|
||||
(STATUS_OPEN, "Open"),
|
||||
(STATUS_IN_PROGRESS, "In Progress"),
|
||||
(STATUS_CLOSED, "Closed"),
|
||||
]
|
||||
|
||||
CATEGORY_GENERAL = "general"
|
||||
CATEGORY_BUG = "bug"
|
||||
CATEGORY_PARTNERSHIP = "partnership"
|
||||
CATEGORY_PRESS = "press"
|
||||
CATEGORY_DATA = "data"
|
||||
CATEGORY_ACCOUNT = "account"
|
||||
|
||||
CATEGORY_CHOICES = [
|
||||
(CATEGORY_GENERAL, "General Inquiry"),
|
||||
(CATEGORY_BUG, "Bug Report"),
|
||||
(CATEGORY_PARTNERSHIP, "Partnership"),
|
||||
(CATEGORY_PRESS, "Press/Media"),
|
||||
(CATEGORY_DATA, "Data Correction"),
|
||||
(CATEGORY_ACCOUNT, "Account Issue"),
|
||||
]
|
||||
state_field_name = "status"
|
||||
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
@@ -40,10 +31,18 @@ class Ticket(TrackedModel):
|
||||
help_text="User who submitted the ticket (optional)",
|
||||
)
|
||||
|
||||
category = models.CharField(
|
||||
# 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",
|
||||
max_length=20,
|
||||
choices=CATEGORY_CHOICES,
|
||||
default=CATEGORY_GENERAL,
|
||||
default="general",
|
||||
db_index=True,
|
||||
help_text="Category of the ticket",
|
||||
)
|
||||
@@ -51,22 +50,259 @@ class Ticket(TrackedModel):
|
||||
message = models.TextField()
|
||||
email = models.EmailField(help_text="Contact email", blank=True)
|
||||
|
||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default=STATUS_OPEN, db_index=True)
|
||||
# FSM-managed status field
|
||||
status = RichFSMField(
|
||||
choice_group="ticket_statuses",
|
||||
domain="support",
|
||||
max_length=20,
|
||||
default="open",
|
||||
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):
|
||||
"""
|
||||
@@ -76,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,
|
||||
@@ -114,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",
|
||||
)
|
||||
@@ -166,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")
|
||||
|
||||
|
||||
|
||||
@@ -1,31 +1,126 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from apps.accounts.serializers import UserSerializer
|
||||
from apps.core.choices.serializers import RichChoiceSerializerField
|
||||
|
||||
from .models import Ticket
|
||||
from .models import EmailThread, Ticket
|
||||
|
||||
|
||||
class SubmitterProfileSerializer(serializers.Serializer):
|
||||
"""Nested serializer for submitter profile data."""
|
||||
|
||||
display_name = serializers.CharField(source="profile.display_name", allow_null=True)
|
||||
created_at = serializers.DateTimeField(source="date_joined", allow_null=True)
|
||||
coaster_count = serializers.IntegerField(source="profile.coaster_credit_count", allow_null=True, default=0)
|
||||
ride_count = serializers.IntegerField(source="profile.ride_credit_count", allow_null=True, default=0)
|
||||
park_count = serializers.IntegerField(source="profile.park_credit_count", allow_null=True, default=0)
|
||||
review_count = serializers.IntegerField(source="profile.review_count", allow_null=True, default=0)
|
||||
avatar_url = serializers.CharField(source="profile.avatar_url", allow_null=True)
|
||||
|
||||
|
||||
class TicketSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for Ticket model with full frontend compatibility."""
|
||||
|
||||
# User fields
|
||||
user = UserSerializer(read_only=True)
|
||||
user_id = serializers.UUIDField(source="user.id", read_only=True, allow_null=True)
|
||||
submitter_username = serializers.CharField(source="user.username", read_only=True, allow_null=True)
|
||||
submitter_reputation = serializers.SerializerMethodField()
|
||||
submitter_profile = serializers.SerializerMethodField()
|
||||
|
||||
# Choice display fields
|
||||
category_display = serializers.CharField(source="get_category_display", read_only=True)
|
||||
status_display = serializers.CharField(source="get_status_display", read_only=True)
|
||||
|
||||
# Computed fields
|
||||
thread_id = serializers.CharField(read_only=True)
|
||||
response_count = serializers.IntegerField(read_only=True)
|
||||
last_admin_response_at = serializers.DateTimeField(read_only=True, allow_null=True)
|
||||
|
||||
# Resolution tracking (alias resolved_by username)
|
||||
resolved_by_username = serializers.CharField(source="resolved_by.username", read_only=True, allow_null=True)
|
||||
|
||||
# Admin fields
|
||||
assigned_to_username = serializers.CharField(source="assigned_to.username", read_only=True, allow_null=True)
|
||||
archived_by_username = serializers.CharField(source="archived_by.username", read_only=True, allow_null=True)
|
||||
|
||||
class Meta:
|
||||
model = Ticket
|
||||
fields = [
|
||||
# Core fields
|
||||
"id",
|
||||
"user",
|
||||
"category",
|
||||
"category_display",
|
||||
"subject",
|
||||
"message",
|
||||
"email",
|
||||
"status",
|
||||
"status_display",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
# User/submitter fields
|
||||
"user",
|
||||
"user_id",
|
||||
"submitter_username",
|
||||
"submitter_reputation",
|
||||
"submitter_profile",
|
||||
"name",
|
||||
"email",
|
||||
# Ticket content
|
||||
"subject",
|
||||
"message",
|
||||
"category",
|
||||
"category_display",
|
||||
# Status
|
||||
"status",
|
||||
"status_display",
|
||||
# Ticket number
|
||||
"ticket_number",
|
||||
# Admin management
|
||||
"admin_notes",
|
||||
"assigned_to",
|
||||
"assigned_to_username",
|
||||
# Resolution
|
||||
"resolved_at",
|
||||
"resolved_by",
|
||||
"resolved_by_username",
|
||||
# Thread info
|
||||
"thread_id",
|
||||
"last_admin_response_at",
|
||||
"response_count",
|
||||
# Archive
|
||||
"archived_at",
|
||||
"archived_by",
|
||||
"archived_by_username",
|
||||
]
|
||||
read_only_fields = ["id", "status", "created_at", "updated_at", "user"]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"user",
|
||||
"user_id",
|
||||
"submitter_username",
|
||||
"submitter_reputation",
|
||||
"submitter_profile",
|
||||
"ticket_number",
|
||||
"thread_id",
|
||||
"response_count",
|
||||
"last_admin_response_at",
|
||||
]
|
||||
|
||||
def get_submitter_reputation(self, obj):
|
||||
"""Get the submitter's reputation score."""
|
||||
if obj.user and hasattr(obj.user, "profile"):
|
||||
return getattr(obj.user.profile, "reputation", 0)
|
||||
return None
|
||||
|
||||
def get_submitter_profile(self, obj):
|
||||
"""Get a subset of profile data for display."""
|
||||
if not obj.user or not hasattr(obj.user, "profile"):
|
||||
return None
|
||||
profile = obj.user.profile
|
||||
return {
|
||||
"display_name": getattr(profile, "display_name", None),
|
||||
"created_at": obj.user.date_joined.isoformat() if obj.user.date_joined else None,
|
||||
"coaster_count": getattr(profile, "coaster_credit_count", 0),
|
||||
"ride_count": getattr(profile, "ride_credit_count", 0),
|
||||
"park_count": getattr(profile, "park_credit_count", 0),
|
||||
"review_count": getattr(profile, "review_count", 0),
|
||||
"avatar_url": getattr(profile, "avatar_url", None),
|
||||
}
|
||||
|
||||
def validate(self, data):
|
||||
# Ensure email is provided if user is anonymous
|
||||
@@ -35,6 +130,31 @@ class TicketSerializer(serializers.ModelSerializer):
|
||||
return data
|
||||
|
||||
|
||||
class EmailThreadSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for EmailThread model."""
|
||||
|
||||
# NOTE: Frontend uses submission_id, we provide ticket as that field
|
||||
submission_id = serializers.UUIDField(source="ticket.id", read_only=True)
|
||||
sent_by_username = serializers.CharField(source="sent_by.username", read_only=True, allow_null=True)
|
||||
|
||||
class Meta:
|
||||
model = EmailThread
|
||||
fields = [
|
||||
"id",
|
||||
"created_at",
|
||||
"submission_id",
|
||||
"message_id",
|
||||
"from_email",
|
||||
"to_email",
|
||||
"subject",
|
||||
"body_text",
|
||||
"direction",
|
||||
"sent_by",
|
||||
"sent_by_username",
|
||||
]
|
||||
read_only_fields = ["id", "created_at", "submission_id"]
|
||||
|
||||
|
||||
class ReportSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for Report model."""
|
||||
|
||||
@@ -134,9 +254,141 @@ class ReportCreateSerializer(serializers.ModelSerializer):
|
||||
class ReportResolveSerializer(serializers.Serializer):
|
||||
"""Serializer for resolving reports."""
|
||||
|
||||
status = serializers.ChoiceField(
|
||||
choices=[("resolved", "Resolved"), ("dismissed", "Dismissed")],
|
||||
# Use RichChoiceSerializerField with only resolution statuses
|
||||
status = RichChoiceSerializerField(
|
||||
choice_group="report_statuses",
|
||||
domain="support",
|
||||
default="resolved",
|
||||
)
|
||||
notes = serializers.CharField(required=False, allow_blank=True)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Support Ticket Action Serializers
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class SendReplySerializer(serializers.Serializer):
|
||||
"""
|
||||
Input serializer for send_reply action.
|
||||
|
||||
Validates the request body for sending an email reply to a ticket.
|
||||
Supports both snake_case and camelCase field names for frontend compatibility.
|
||||
"""
|
||||
|
||||
# Primary fields (required=False because we validate manually to support camelCase)
|
||||
reply_body = serializers.CharField(
|
||||
required=False,
|
||||
min_length=1,
|
||||
max_length=50000,
|
||||
help_text="The body text of the email reply",
|
||||
)
|
||||
new_status = RichChoiceSerializerField(
|
||||
choice_group="ticket_statuses",
|
||||
domain="support",
|
||||
required=False,
|
||||
allow_null=True,
|
||||
help_text="Optionally update the ticket status after sending reply",
|
||||
)
|
||||
|
||||
# camelCase aliases for frontend compatibility
|
||||
replyBody = serializers.CharField(required=False, write_only=True)
|
||||
newStatus = serializers.CharField(required=False, write_only=True, allow_null=True)
|
||||
|
||||
def validate(self, data: dict) -> dict:
|
||||
"""Normalize camelCase to snake_case and validate required fields."""
|
||||
# Normalize camelCase to snake_case
|
||||
if "replyBody" in data and data["replyBody"]:
|
||||
data["reply_body"] = data.pop("replyBody")
|
||||
elif "replyBody" in data:
|
||||
data.pop("replyBody")
|
||||
|
||||
if "newStatus" in data and data["newStatus"]:
|
||||
data["new_status"] = data.pop("newStatus")
|
||||
elif "newStatus" in data:
|
||||
data.pop("newStatus")
|
||||
|
||||
# Validate required fields
|
||||
if not data.get("reply_body"):
|
||||
raise serializers.ValidationError({"reply_body": "This field is required."})
|
||||
|
||||
return data
|
||||
|
||||
|
||||
class SendReplyResponseSerializer(serializers.Serializer):
|
||||
"""Response serializer for send_reply action."""
|
||||
|
||||
detail = serializers.CharField()
|
||||
thread_id = serializers.UUIDField()
|
||||
ticket_number = serializers.CharField()
|
||||
|
||||
|
||||
class MergeTicketsSerializer(serializers.Serializer):
|
||||
"""
|
||||
Input serializer for merge tickets action.
|
||||
|
||||
Validates the request body for merging multiple tickets into one.
|
||||
Supports both snake_case and camelCase field names for frontend compatibility.
|
||||
"""
|
||||
|
||||
# Primary fields (required=False because we validate manually to support camelCase)
|
||||
primary_ticket_id = serializers.UUIDField(
|
||||
required=False,
|
||||
help_text="UUID of the primary ticket that will absorb others",
|
||||
)
|
||||
merge_ticket_ids = serializers.ListField(
|
||||
child=serializers.UUIDField(),
|
||||
required=False,
|
||||
min_length=1,
|
||||
help_text="List of ticket UUIDs to merge into the primary",
|
||||
)
|
||||
merge_reason = serializers.CharField(
|
||||
required=False,
|
||||
allow_blank=True,
|
||||
max_length=500,
|
||||
help_text="Optional reason for the merge",
|
||||
)
|
||||
|
||||
# camelCase aliases for frontend compatibility
|
||||
primaryTicketId = serializers.UUIDField(required=False, write_only=True)
|
||||
mergeTicketIds = serializers.ListField(
|
||||
child=serializers.UUIDField(),
|
||||
required=False,
|
||||
write_only=True,
|
||||
)
|
||||
mergeReason = serializers.CharField(required=False, write_only=True, allow_blank=True)
|
||||
|
||||
def validate(self, data: dict) -> dict:
|
||||
"""Normalize camelCase to snake_case and validate required fields."""
|
||||
# Normalize camelCase to snake_case
|
||||
if "primaryTicketId" in data and data["primaryTicketId"]:
|
||||
data["primary_ticket_id"] = data.pop("primaryTicketId")
|
||||
elif "primaryTicketId" in data:
|
||||
data.pop("primaryTicketId")
|
||||
|
||||
if "mergeTicketIds" in data and data["mergeTicketIds"]:
|
||||
data["merge_ticket_ids"] = data.pop("mergeTicketIds")
|
||||
elif "mergeTicketIds" in data:
|
||||
data.pop("mergeTicketIds")
|
||||
|
||||
if "mergeReason" in data and data["mergeReason"]:
|
||||
data["merge_reason"] = data.pop("mergeReason")
|
||||
elif "mergeReason" in data:
|
||||
data.pop("mergeReason")
|
||||
|
||||
# Validate required fields
|
||||
if not data.get("primary_ticket_id"):
|
||||
raise serializers.ValidationError({"primary_ticket_id": "This field is required."})
|
||||
if not data.get("merge_ticket_ids"):
|
||||
raise serializers.ValidationError({"merge_ticket_ids": "This field is required."})
|
||||
|
||||
return data
|
||||
|
||||
|
||||
class MergeTicketsResponseSerializer(serializers.Serializer):
|
||||
"""Response serializer for merge tickets action."""
|
||||
|
||||
detail = serializers.CharField()
|
||||
primaryTicketNumber = serializers.CharField()
|
||||
mergedCount = serializers.IntegerField()
|
||||
threadsConsolidated = serializers.IntegerField()
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
from django.urls import include, path
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from .views import ReportViewSet, TicketViewSet
|
||||
from .views import EmailThreadViewSet, ReportViewSet, TicketViewSet
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r"tickets", TicketViewSet, basename="ticket")
|
||||
router.register(r"reports", ReportViewSet, basename="report")
|
||||
router.register(r"threads", EmailThreadViewSet, basename="email-thread")
|
||||
|
||||
urlpatterns = [
|
||||
path("", include(router.urls)),
|
||||
]
|
||||
|
||||
|
||||
@@ -4,8 +4,9 @@ from rest_framework import filters, permissions, status, viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
|
||||
from .models import Report, Ticket
|
||||
from .models import EmailThread, Report, Ticket
|
||||
from .serializers import (
|
||||
EmailThreadSerializer,
|
||||
ReportCreateSerializer,
|
||||
ReportResolveSerializer,
|
||||
ReportSerializer,
|
||||
@@ -23,17 +24,26 @@ class TicketViewSet(viewsets.ModelViewSet):
|
||||
queryset = Ticket.objects.all()
|
||||
serializer_class = TicketSerializer
|
||||
permission_classes = [permissions.AllowAny] # We handle granular perms in get_queryset/perform_create
|
||||
filter_backends = [DjangoFilterBackend, filters.OrderingFilter]
|
||||
filterset_fields = ["status", "category"]
|
||||
ordering_fields = ["created_at", "status"]
|
||||
filter_backends = [DjangoFilterBackend, filters.OrderingFilter, filters.SearchFilter]
|
||||
filterset_fields = ["status", "category", "archived_at"]
|
||||
search_fields = ["name", "email", "subject", "ticket_number"]
|
||||
ordering_fields = ["created_at", "status", "ticket_number"]
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def get_queryset(self):
|
||||
user = self.request.user
|
||||
qs = Ticket.objects.select_related(
|
||||
"user",
|
||||
"user__profile",
|
||||
"assigned_to",
|
||||
"resolved_by",
|
||||
"archived_by",
|
||||
).prefetch_related("email_threads")
|
||||
|
||||
if user.is_staff:
|
||||
return Ticket.objects.all()
|
||||
return qs
|
||||
if user.is_authenticated:
|
||||
return Ticket.objects.filter(user=user)
|
||||
return qs.filter(user=user)
|
||||
return Ticket.objects.none() # Guests can't list tickets
|
||||
|
||||
def perform_create(self, serializer):
|
||||
@@ -42,6 +52,337 @@ class TicketViewSet(viewsets.ModelViewSet):
|
||||
else:
|
||||
serializer.save()
|
||||
|
||||
@action(detail=True, methods=["post"], permission_classes=[permissions.IsAdminUser])
|
||||
def send_reply(self, request, pk=None):
|
||||
"""
|
||||
Send an email reply to the ticket submitter.
|
||||
|
||||
Creates an EmailThread record and sends the email via ForwardEmail.
|
||||
Optionally updates the ticket status.
|
||||
"""
|
||||
from typing import Any
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.sites.models import Site
|
||||
|
||||
from django_forwardemail.services import EmailService
|
||||
|
||||
from .serializers import SendReplySerializer
|
||||
|
||||
# Validate input
|
||||
serializer = SendReplySerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
validated_data: dict[str, Any] = serializer.validated_data
|
||||
|
||||
ticket = self.get_object()
|
||||
|
||||
if not ticket.email:
|
||||
return Response(
|
||||
{"detail": "Ticket has no email address to reply to"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
reply_body: str = validated_data["reply_body"]
|
||||
new_status: str | None = validated_data.get("new_status")
|
||||
|
||||
# Build email subject with ticket number for threading
|
||||
subject = f"Re: [{ticket.ticket_number}] {ticket.subject}"
|
||||
|
||||
# Get the support from email with proper formatting
|
||||
from_email: str = getattr(settings, "DEFAULT_FROM_EMAIL", "ThrillWiki Support <support@thrillwiki.com>")
|
||||
|
||||
try:
|
||||
# Get the current site for ForwardEmail configuration
|
||||
# ForwardEmail requires a Site object, not RequestSite
|
||||
try:
|
||||
site = Site.objects.get_current()
|
||||
except Site.DoesNotExist:
|
||||
site = Site.objects.first()
|
||||
if site is None:
|
||||
return Response(
|
||||
{"detail": "No site configured for email sending"},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
# Send email via ForwardEmail service
|
||||
EmailService.send_email(
|
||||
to=ticket.email,
|
||||
subject=subject,
|
||||
text=reply_body,
|
||||
from_email=from_email,
|
||||
reply_to=from_email, # Ensure replies come back to support
|
||||
site=site,
|
||||
)
|
||||
|
||||
# Create EmailThread record for audit trail
|
||||
email_thread = EmailThread.objects.create(
|
||||
ticket=ticket,
|
||||
from_email=from_email,
|
||||
to_email=ticket.email,
|
||||
subject=subject,
|
||||
body_text=reply_body,
|
||||
direction="outbound",
|
||||
sent_by=request.user,
|
||||
)
|
||||
|
||||
# Update ticket status if provided
|
||||
if new_status and new_status != ticket.status:
|
||||
ticket.status = new_status
|
||||
if new_status in ("resolved", "closed"):
|
||||
ticket.resolved_at = timezone.now()
|
||||
ticket.resolved_by = request.user
|
||||
ticket.save()
|
||||
|
||||
return Response({
|
||||
"detail": "Reply sent successfully",
|
||||
"thread_id": str(email_thread.id),
|
||||
"ticket_number": ticket.ticket_number,
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
# Log the error for debugging
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.exception("Failed to send ticket reply email")
|
||||
|
||||
return Response(
|
||||
{"detail": f"Failed to send email: {str(e)}"},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
@action(detail=False, methods=["post"], permission_classes=[permissions.IsAdminUser])
|
||||
def merge(self, request):
|
||||
"""
|
||||
Merge multiple tickets into a primary ticket.
|
||||
|
||||
Moves all EmailThread records to the primary ticket and archives merged tickets.
|
||||
"""
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from .serializers import MergeTicketsSerializer
|
||||
|
||||
# Validate input
|
||||
serializer = MergeTicketsSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
validated_data: dict[str, Any] = serializer.validated_data
|
||||
|
||||
primary_id: UUID = validated_data["primary_ticket_id"]
|
||||
merge_ids: list[UUID] = validated_data["merge_ticket_ids"]
|
||||
reason: str = validated_data.get("merge_reason", "")
|
||||
|
||||
# Get primary ticket
|
||||
try:
|
||||
primary_ticket = Ticket.objects.get(pk=primary_id)
|
||||
except Ticket.DoesNotExist:
|
||||
return Response(
|
||||
{"detail": f"Primary ticket {primary_id} not found"},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
# Get tickets to merge (exclud primary if accidentally included)
|
||||
tickets_to_merge = Ticket.objects.filter(pk__in=merge_ids).exclude(pk=primary_id)
|
||||
if tickets_to_merge.count() == 0:
|
||||
return Response(
|
||||
{"detail": "No valid tickets found to merge"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
merged_count = 0
|
||||
threads_consolidated = 0
|
||||
merged_ticket_numbers: list[str] = []
|
||||
|
||||
for ticket in tickets_to_merge:
|
||||
# Move all email threads to primary ticket
|
||||
thread_count = EmailThread.objects.filter(ticket=ticket).update(ticket=primary_ticket)
|
||||
threads_consolidated += thread_count
|
||||
|
||||
# Record the merged ticket number
|
||||
merged_ticket_numbers.append(ticket.ticket_number)
|
||||
|
||||
# Archive the merged ticket with merge history
|
||||
ticket.archived_at = timezone.now()
|
||||
ticket.archived_by = request.user
|
||||
existing_notes = ticket.admin_notes or ""
|
||||
ticket.admin_notes = (
|
||||
f"{existing_notes}\n\n"
|
||||
f"[MERGED] Merged into {primary_ticket.ticket_number} by {request.user.username} "
|
||||
f"on {timezone.now().isoformat()}. Reason: {reason or 'Not specified'}"
|
||||
).strip()
|
||||
ticket.save()
|
||||
|
||||
merged_count += 1
|
||||
|
||||
# Update primary ticket with merge history
|
||||
existing_merged = primary_ticket.admin_notes or ""
|
||||
merge_note = (
|
||||
f"\n\n[MERGE HISTORY] Absorbed tickets: {', '.join(merged_ticket_numbers)} "
|
||||
f"({threads_consolidated} threads consolidated) by {request.user.username}"
|
||||
)
|
||||
primary_ticket.admin_notes = existing_merged + merge_note
|
||||
primary_ticket.save()
|
||||
|
||||
return Response({
|
||||
"detail": "Tickets merged successfully",
|
||||
"primaryTicketNumber": primary_ticket.ticket_number,
|
||||
"mergedCount": merged_count,
|
||||
"threadsConsolidated": threads_consolidated,
|
||||
})
|
||||
|
||||
# =========================================================================
|
||||
# FSM Transition Endpoints
|
||||
# =========================================================================
|
||||
|
||||
@action(detail=True, methods=["post"], permission_classes=[permissions.IsAdminUser])
|
||||
def start_progress(self, request, pk=None):
|
||||
"""
|
||||
Start working on a ticket.
|
||||
Transition: open -> in_progress
|
||||
"""
|
||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||
|
||||
ticket = self.get_object()
|
||||
try:
|
||||
ticket.start_progress(user=request.user)
|
||||
return Response({
|
||||
"detail": "Ticket marked as in progress",
|
||||
"ticketNumber": ticket.ticket_number,
|
||||
"status": ticket.status,
|
||||
})
|
||||
except DjangoValidationError as e:
|
||||
return Response(
|
||||
{"detail": str(e.message if hasattr(e, 'message') else e)},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
@action(detail=True, methods=["post"], permission_classes=[permissions.IsAdminUser], url_path="close")
|
||||
def close_ticket(self, request, pk=None):
|
||||
"""
|
||||
Close/resolve a ticket.
|
||||
Transition: open|in_progress -> closed
|
||||
"""
|
||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||
|
||||
ticket = self.get_object()
|
||||
notes = request.data.get("notes", "")
|
||||
|
||||
try:
|
||||
ticket.close(user=request.user, notes=notes)
|
||||
return Response({
|
||||
"detail": "Ticket closed successfully",
|
||||
"ticketNumber": ticket.ticket_number,
|
||||
"status": ticket.status,
|
||||
"resolvedAt": ticket.resolved_at.isoformat() if ticket.resolved_at else None,
|
||||
})
|
||||
except DjangoValidationError as e:
|
||||
return Response(
|
||||
{"detail": str(e.message if hasattr(e, 'message') else e)},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
@action(detail=True, methods=["post"], permission_classes=[permissions.IsAdminUser])
|
||||
def reopen(self, request, pk=None):
|
||||
"""
|
||||
Reopen a closed ticket.
|
||||
Transition: closed -> open
|
||||
"""
|
||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||
|
||||
ticket = self.get_object()
|
||||
reason = request.data.get("reason", "")
|
||||
|
||||
try:
|
||||
ticket.reopen(user=request.user, reason=reason)
|
||||
return Response({
|
||||
"detail": "Ticket reopened",
|
||||
"ticketNumber": ticket.ticket_number,
|
||||
"status": ticket.status,
|
||||
})
|
||||
except DjangoValidationError as e:
|
||||
return Response(
|
||||
{"detail": str(e.message if hasattr(e, 'message') else e)},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
@action(detail=True, methods=["post"], permission_classes=[permissions.IsAdminUser])
|
||||
def archive(self, request, pk=None):
|
||||
"""Archive a ticket."""
|
||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||
|
||||
ticket = self.get_object()
|
||||
reason = request.data.get("reason", "")
|
||||
|
||||
try:
|
||||
ticket.archive(user=request.user, reason=reason)
|
||||
return Response({
|
||||
"detail": "Ticket archived",
|
||||
"ticketNumber": ticket.ticket_number,
|
||||
"archivedAt": ticket.archived_at.isoformat() if ticket.archived_at else None,
|
||||
})
|
||||
except DjangoValidationError as e:
|
||||
return Response(
|
||||
{"detail": str(e.message if hasattr(e, 'message') else e)},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
@action(detail=True, methods=["post"], permission_classes=[permissions.IsAdminUser])
|
||||
def unarchive(self, request, pk=None):
|
||||
"""Restore an archived ticket."""
|
||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||
|
||||
ticket = self.get_object()
|
||||
|
||||
try:
|
||||
ticket.unarchive(user=request.user)
|
||||
return Response({
|
||||
"detail": "Ticket unarchived",
|
||||
"ticketNumber": ticket.ticket_number,
|
||||
})
|
||||
except DjangoValidationError as e:
|
||||
return Response(
|
||||
{"detail": str(e.message if hasattr(e, 'message') else e)},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
@action(detail=True, methods=["get"], permission_classes=[permissions.IsAdminUser])
|
||||
def available_transitions(self, request, pk=None):
|
||||
"""
|
||||
Get available transitions for a ticket.
|
||||
Uses StateMachineMixin to return FSM-aware transition metadata.
|
||||
"""
|
||||
ticket = self.get_object()
|
||||
transitions = ticket.get_available_user_transitions(request.user)
|
||||
|
||||
return Response({
|
||||
"ticketNumber": ticket.ticket_number,
|
||||
"currentStatus": ticket.status,
|
||||
"currentStatusDisplay": ticket.get_status_display(),
|
||||
"availableTransitions": transitions,
|
||||
})
|
||||
|
||||
|
||||
class EmailThreadViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
ViewSet for email thread entries.
|
||||
Staff only for full access.
|
||||
"""
|
||||
|
||||
queryset = EmailThread.objects.select_related("ticket", "sent_by").all()
|
||||
serializer_class = EmailThreadSerializer
|
||||
permission_classes = [permissions.IsAdminUser]
|
||||
filter_backends = [DjangoFilterBackend, filters.OrderingFilter]
|
||||
filterset_fields = ["ticket", "direction"]
|
||||
ordering_fields = ["created_at"]
|
||||
ordering = ["created_at"]
|
||||
|
||||
def get_queryset(self):
|
||||
# Support filtering by submission_id (which is ticket_id in our model)
|
||||
qs = super().get_queryset()
|
||||
submission_id = self.request.query_params.get("submission_id")
|
||||
if submission_id:
|
||||
qs = qs.filter(ticket_id=submission_id)
|
||||
return qs
|
||||
|
||||
|
||||
class ReportViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
|
||||
@@ -86,6 +86,7 @@ THIRD_PARTY_APPS = [
|
||||
"allauth.socialaccount",
|
||||
"allauth.socialaccount.providers.google",
|
||||
"allauth.socialaccount.providers.discord",
|
||||
"allauth.headless", # Headless API for SPA/mobile passkey login
|
||||
"turnstile", # Cloudflare Turnstile CAPTCHA (django-turnstile package)
|
||||
"django_cleanup",
|
||||
"django_filters",
|
||||
|
||||
@@ -73,11 +73,11 @@ CACHE_MIDDLEWARE_KEY_PREFIX = "thrillwiki_dev"
|
||||
# =============================================================================
|
||||
# Use ForwardEmail for actual sending, or console for debugging
|
||||
|
||||
# Console backend for debugging (uncomment to use):
|
||||
# EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
|
||||
# Console backend for debugging (recommended for local development):
|
||||
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
|
||||
|
||||
# ForwardEmail backend for actual email sending:
|
||||
EMAIL_BACKEND = "django_forwardemail.backends.ForwardEmailBackend"
|
||||
# ForwardEmail backend for actual email sending (uncomment for production testing):
|
||||
# EMAIL_BACKEND = "django_forwardemail.backends.ForwardEmailBackend"
|
||||
|
||||
# =============================================================================
|
||||
# Security Settings (Relaxed for Development)
|
||||
@@ -87,6 +87,9 @@ SECURE_SSL_REDIRECT = False
|
||||
SESSION_COOKIE_SECURE = False
|
||||
CSRF_COOKIE_SECURE = False
|
||||
|
||||
# Email verification - optional for local development to allow passkey login
|
||||
ACCOUNT_EMAIL_VERIFICATION = "optional"
|
||||
|
||||
# =============================================================================
|
||||
# Development Apps
|
||||
# =============================================================================
|
||||
|
||||
@@ -101,6 +101,7 @@ CORS_ALLOW_HEADERS = [
|
||||
"x-csrftoken",
|
||||
"x-requested-with",
|
||||
"x-api-version",
|
||||
"x-session-token", # Required for allauth headless app client
|
||||
]
|
||||
|
||||
# HTTP methods allowed for CORS requests
|
||||
|
||||
@@ -114,6 +114,26 @@ ACCOUNT_LOGIN_BY_CODE_MAX_ATTEMPTS = config("ACCOUNT_LOGIN_BY_CODE_MAX_ATTEMPTS"
|
||||
ACCOUNT_LOGIN_BY_CODE_TIMEOUT = config("ACCOUNT_LOGIN_BY_CODE_TIMEOUT", default=300, cast=int)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Headless API Configuration
|
||||
# =============================================================================
|
||||
# https://docs.allauth.org/en/latest/headless/configuration.html
|
||||
|
||||
# Frontend URL for email links (password reset, email verification, etc.)
|
||||
HEADLESS_FRONTEND_URLS = {
|
||||
"account_confirm_email": config("FRONTEND_URL", default="http://localhost:5173") + "/auth/callback?key={key}",
|
||||
"account_reset_password": config("FRONTEND_URL", default="http://localhost:5173") + "/auth/reset-password?key={key}",
|
||||
"account_signup": config("FRONTEND_URL", default="http://localhost:5173") + "/auth?tab=signup",
|
||||
"socialaccount_login_error": config("FRONTEND_URL", default="http://localhost:5173") + "/auth?error=social",
|
||||
}
|
||||
|
||||
# Set to True since our frontend is a separate SPA
|
||||
HEADLESS_ONLY = config("HEADLESS_ONLY", default=False, cast=bool)
|
||||
|
||||
# Allow both "app" and "browser" clients for flexibility
|
||||
# "browser" uses cookies, "app" uses Authorization header
|
||||
HEADLESS_CLIENTS = ("app", "browser")
|
||||
|
||||
# =============================================================================
|
||||
# Celery Configuration
|
||||
# =============================================================================
|
||||
|
||||
@@ -30,7 +30,7 @@ dependencies = [
|
||||
# =============================================================================
|
||||
# Image Processing & Media
|
||||
# =============================================================================
|
||||
"Pillow>=10.4.0,<11.2",
|
||||
"Pillow>=12.0",
|
||||
"django-cleanup>=8.1.0",
|
||||
"piexif>=1.1.3",
|
||||
"django-cloudflareimages-toolkit>=1.0.6",
|
||||
@@ -193,6 +193,7 @@ output = "coverage.xml"
|
||||
|
||||
[tool.uv.sources]
|
||||
python-json-logger = { url = "https://github.com/nhairs/python-json-logger/releases/download/v3.0.0/python_json_logger-3.0.0-py3-none-any.whl" }
|
||||
# Removed django-celery-beat git source - using constraint override in workspace root
|
||||
|
||||
# =============================================================================
|
||||
# Ruff Configuration
|
||||
|
||||
@@ -78,6 +78,8 @@ urlpatterns = [
|
||||
path("accounts/", include("apps.accounts.urls")),
|
||||
# Default allauth URLs (for social auth and other features)
|
||||
path("accounts/", include("allauth.urls")),
|
||||
# Allauth headless API (for SPA passkey login, WebAuthn, etc.)
|
||||
path("_allauth/", include("allauth.headless.urls")),
|
||||
path(
|
||||
"accounts/email-required/",
|
||||
accounts_views.email_required,
|
||||
|
||||
@@ -608,28 +608,118 @@ After authentication completes with JWT enabled:
|
||||
Authorization: Bearer <access-token>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Current ThrillWiki Implementation Summary
|
||||
|
||||
ThrillWiki already has these allauth features configured:
|
||||
ThrillWiki uses a hybrid authentication system with django-allauth for MFA and social auth, and SimpleJWT for API tokens.
|
||||
|
||||
### Backend Configuration
|
||||
|
||||
| Feature | Status | Notes |
|
||||
|---------|--------|-------|
|
||||
| Password Auth | ✅ Configured | Email + username login |
|
||||
| Password Auth | ✅ Active | Email + username login |
|
||||
| Email Verification | ✅ Mandatory | With resend support |
|
||||
| TOTP MFA | ✅ Configured | 6-digit codes, 30s period |
|
||||
| WebAuthn/Passkeys | ✅ Configured | Passkey login enabled |
|
||||
| Google OAuth | ✅ Configured | Needs admin SocialApp |
|
||||
| Discord OAuth | ✅ Configured | Needs admin SocialApp |
|
||||
| TOTP MFA | ✅ Active | 6-digit codes, 30s period |
|
||||
| WebAuthn/Passkeys | ✅ Active | Passkey login enabled, counts as MFA |
|
||||
| Google OAuth | ✅ Configured | Requires admin SocialApp setup |
|
||||
| Discord OAuth | ✅ Configured | Requires admin SocialApp setup |
|
||||
| Magic Link | ✅ Configured | 5-minute timeout |
|
||||
| JWT Tokens | ❌ Not configured | Using SimpleJWT instead |
|
||||
| JWT Tokens | ✅ SimpleJWT | 15min access, 7 day refresh |
|
||||
|
||||
### Recommendation
|
||||
### Frontend MFA Integration (Updated 2026-01-10)
|
||||
|
||||
To use allauth's native JWT support instead of SimpleJWT:
|
||||
The frontend recognizes both TOTP and Passkeys as valid MFA factors:
|
||||
|
||||
1. Add `"allauth.headless"` to INSTALLED_APPS
|
||||
2. Configure `HEADLESS_TOKEN_STRATEGY` and JWT settings
|
||||
3. Replace `rest_framework_simplejwt` authentication with `JWTTokenAuthentication`
|
||||
4. Add `/_allauth/` URL routes
|
||||
```typescript
|
||||
// authService.ts - getEnrolledFactors()
|
||||
// Checks both Supabase TOTP AND Django passkeys
|
||||
const mfaStatus = await djangoClient.rpc('get_mfa_status', {});
|
||||
if (statusData.passkey_enabled && statusData.passkey_count > 0) {
|
||||
factors.push({ id: 'passkey', factor_type: 'webauthn', ... });
|
||||
}
|
||||
```
|
||||
|
||||
### Admin Panel MFA Requirements
|
||||
|
||||
Admins and moderators must have MFA enabled to access protected routes:
|
||||
|
||||
1. `useAdminGuard()` hook checks MFA enrollment via `useRequireMFA()`
|
||||
2. `getEnrolledFactors()` queries Django's `get_mfa_status` endpoint
|
||||
3. Backend returns `has_second_factor: true` if TOTP or Passkey is enabled
|
||||
4. Users with only passkeys (no TOTP) now pass the MFA requirement
|
||||
|
||||
### API Endpoints Reference
|
||||
|
||||
#### Authentication
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/api/v1/auth/login/` | POST | Password login |
|
||||
| `/api/v1/auth/login/mfa-verify/` | POST | Complete MFA (TOTP or Passkey) |
|
||||
| `/api/v1/auth/signup/` | POST | Register new account |
|
||||
| `/api/v1/auth/logout/` | POST | Logout, blacklist tokens |
|
||||
| `/api/v1/auth/token/refresh/` | POST | Refresh JWT access token |
|
||||
|
||||
#### MFA (TOTP)
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/api/v1/auth/mfa/status/` | GET | Get MFA status (TOTP + Passkey) |
|
||||
| `/api/v1/auth/mfa/totp/setup/` | POST | Start TOTP enrollment |
|
||||
| `/api/v1/auth/mfa/totp/activate/` | POST | Activate with 6-digit code |
|
||||
| `/api/v1/auth/mfa/totp/deactivate/` | POST | Remove TOTP (requires password) |
|
||||
|
||||
#### Passkeys (WebAuthn)
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/api/v1/auth/passkey/status/` | GET | List registered passkeys |
|
||||
| `/api/v1/auth/passkey/registration-options/` | GET | Get WebAuthn creation options |
|
||||
| `/api/v1/auth/passkey/register/` | POST | Complete passkey registration |
|
||||
| `/api/v1/auth/passkey/login-options/` | POST | Get auth options (uses mfa_token) |
|
||||
| `/api/v1/auth/passkey/{id}/` | DELETE | Remove passkey |
|
||||
|
||||
#### Social Authentication
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/api/v1/auth/social/providers/` | GET | List configured providers |
|
||||
| `/api/v1/auth/social/connect/{provider}/` | POST | Start OAuth flow |
|
||||
| `/api/v1/auth/social/disconnect/{provider}/` | POST | Unlink provider |
|
||||
|
||||
### Login Flow with MFA
|
||||
|
||||
```
|
||||
1. POST /api/v1/auth/login/ {username, password}
|
||||
└── If MFA enabled: Returns {mfa_required: true, mfa_token, mfa_types: ["totp", "webauthn"]}
|
||||
|
||||
2a. TOTP Verification:
|
||||
POST /api/v1/auth/login/mfa-verify/ {mfa_token, code: "123456"}
|
||||
|
||||
2b. Passkey Verification:
|
||||
POST /api/v1/auth/passkey/login-options/ {mfa_token} ← Get challenge
|
||||
Browser: navigator.credentials.get() ← User authenticates
|
||||
POST /api/v1/auth/login/mfa-verify/ {mfa_token, credential: {...}}
|
||||
|
||||
3. Returns: {access, refresh, user, message: "Login successful"}
|
||||
```
|
||||
|
||||
### Frontend Components
|
||||
|
||||
| Component | Purpose |
|
||||
|-----------|---------|
|
||||
| `MFAChallenge.tsx` | TOTP code entry during login |
|
||||
| `MFAEnrollmentRequired.tsx` | Prompts admin/mod to set up MFA |
|
||||
| `MFAGuard.tsx` | Wraps routes requiring MFA |
|
||||
| `useRequireMFA` | Hook checking MFA enrollment |
|
||||
| `useAdminGuard` | Combines auth + role + MFA checks |
|
||||
|
||||
### Admin MFA Requirements
|
||||
|
||||
Moderators, admins, and superusers **must have MFA enrolled** to access admin pages.
|
||||
|
||||
The system uses **enrollment-based verification** rather than per-session AAL2 tokens:
|
||||
- MFA verification happens at login time via the `mfa_token` flow
|
||||
- Django-allauth doesn't embed AAL claims in JWT tokens
|
||||
- The frontend checks if the user has TOTP or passkey enrolled
|
||||
- Mid-session MFA step-up is not supported (user must re-login)
|
||||
|
||||
This means:
|
||||
- `useRequireMFA` returns `hasMFA: true` if user has any factor enrolled
|
||||
- `useAdminGuard` blocks access if `needsEnrollment` is true
|
||||
- Users prompted to enroll MFA on their first admin page visit
|
||||
|
||||
@@ -8,6 +8,13 @@ readme = "docs/README.md"
|
||||
[tool.uv.workspace]
|
||||
members = ["backend"]
|
||||
|
||||
# Django 6 upgrade commented out - requires CheckConstraint API migration
|
||||
# See: https://docs.djangoproject.com/en/6.0/releases/6.0/#features-removed-in-6-0
|
||||
# CheckConstraint(check=...) → CheckConstraint(condition=...) in 50+ files
|
||||
# [tool.uv]
|
||||
# override-dependencies = ["django>=6.0"]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Ruff Configuration (shared across workspace)
|
||||
# =============================================================================
|
||||
|
||||
174
scripts/lint_choices.py
Executable file
174
scripts/lint_choices.py
Executable file
@@ -0,0 +1,174 @@
|
||||
#!/usr/bin/env python
|
||||
"""
|
||||
RichChoiceField Enforcement Lint Script
|
||||
|
||||
This script checks for prohibited choice patterns in the codebase.
|
||||
Exit code 0 = no violations found, exit code 1 = violations found.
|
||||
|
||||
Usage:
|
||||
python scripts/lint_choices.py
|
||||
python scripts/lint_choices.py --fix # Show fix suggestions
|
||||
|
||||
Add to CI:
|
||||
python scripts/lint_choices.py || exit 1
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Patterns to detect prohibited choice usage
|
||||
PROHIBITED_PATTERNS = [
|
||||
# TextChoices / IntegerChoices class definitions
|
||||
(r'class\s+\w+\s*\(\s*models\.(TextChoices|IntegerChoices)\s*\)',
|
||||
'models.TextChoices/IntegerChoices class definition'),
|
||||
|
||||
# Inline tuple choices in CharField
|
||||
(r'choices\s*=\s*\[\s*\(\s*["\']',
|
||||
'Inline tuple choices'),
|
||||
|
||||
# Direct reference to .choices attribute
|
||||
(r'choices\s*=\s*\w+\.choices',
|
||||
'Reference to inner TextChoices.choices'),
|
||||
]
|
||||
|
||||
# Directories/files to exclude
|
||||
EXCLUDE_PATTERNS = [
|
||||
'*/migrations/*',
|
||||
'*/.venv/*',
|
||||
'*/node_modules/*',
|
||||
'*/__pycache__/*',
|
||||
'*.pyc',
|
||||
'lint_choices.py', # Exclude this script
|
||||
]
|
||||
|
||||
# Files allowed to define TextChoices (infrastructure only)
|
||||
ALLOWED_EXCEPTION_FILES = [
|
||||
# Core choice infrastructure files can reference these patterns
|
||||
'apps/core/choices/',
|
||||
]
|
||||
|
||||
|
||||
def should_exclude(path: Path) -> bool:
|
||||
"""Check if path should be excluded from linting."""
|
||||
path_str = str(path)
|
||||
for pattern in EXCLUDE_PATTERNS:
|
||||
if pattern.startswith('*/'):
|
||||
if pattern[2:].rstrip('/*') in path_str:
|
||||
return True
|
||||
elif pattern.endswith('/*'):
|
||||
if path_str.startswith(pattern[:-2]):
|
||||
return True
|
||||
elif pattern in path_str:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def is_exception_file(path: Path) -> bool:
|
||||
"""Check if file is an allowed exception."""
|
||||
path_str = str(path)
|
||||
for exception in ALLOWED_EXCEPTION_FILES:
|
||||
if exception in path_str:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def scan_file(filepath: Path) -> list[tuple[int, str, str]]:
|
||||
"""Scan a file for prohibited patterns. Returns list of (line_num, line, pattern_name)."""
|
||||
violations = []
|
||||
|
||||
if should_exclude(filepath) or is_exception_file(filepath):
|
||||
return violations
|
||||
|
||||
try:
|
||||
content = filepath.read_text(encoding='utf-8')
|
||||
lines = content.split('\n')
|
||||
|
||||
for i, line in enumerate(lines, 1):
|
||||
# Skip comments
|
||||
stripped = line.strip()
|
||||
if stripped.startswith('#'):
|
||||
continue
|
||||
|
||||
for pattern, description in PROHIBITED_PATTERNS:
|
||||
if re.search(pattern, line):
|
||||
violations.append((i, line.strip(), description))
|
||||
break # Only report one violation per line
|
||||
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not read {filepath}: {e}", file=sys.stderr)
|
||||
|
||||
return violations
|
||||
|
||||
|
||||
def scan_directory(root_dir: Path) -> dict[Path, list]:
|
||||
"""Scan all Python files in directory for violations."""
|
||||
all_violations = {}
|
||||
|
||||
for filepath in root_dir.rglob('*.py'):
|
||||
violations = scan_file(filepath)
|
||||
if violations:
|
||||
all_violations[filepath] = violations
|
||||
|
||||
return all_violations
|
||||
|
||||
|
||||
def print_violations(violations: dict, show_fix: bool = False):
|
||||
"""Print violations in a readable format."""
|
||||
total = 0
|
||||
|
||||
for filepath, file_violations in sorted(violations.items()):
|
||||
print(f"\n\033[1;31m{filepath}\033[0m")
|
||||
for line_num, line_content, description in file_violations:
|
||||
print(f" Line {line_num}: [{description}]")
|
||||
print(f" {line_content[:80]}{'...' if len(line_content) > 80 else ''}")
|
||||
total += 1
|
||||
|
||||
if show_fix:
|
||||
print(f" \033[1;33mFix:\033[0m Use RichChoiceField(choice_group='...', domain='...')")
|
||||
|
||||
return total
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='Lint for prohibited choice patterns')
|
||||
parser.add_argument('--fix', action='store_true', help='Show fix suggestions')
|
||||
parser.add_argument('path', nargs='?', default='apps', help='Path to scan (default: apps)')
|
||||
args = parser.parse_args()
|
||||
|
||||
# Find backend directory
|
||||
script_dir = Path(__file__).parent
|
||||
backend_dir = script_dir.parent
|
||||
if (backend_dir / 'apps').exists():
|
||||
scan_path = backend_dir / args.path
|
||||
elif (backend_dir / 'backend' / 'apps').exists():
|
||||
scan_path = backend_dir / 'backend' / args.path
|
||||
else:
|
||||
print("Error: Could not find apps directory", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Scanning {scan_path} for prohibited choice patterns...")
|
||||
print("=" * 60)
|
||||
|
||||
violations = scan_directory(scan_path)
|
||||
|
||||
if violations:
|
||||
total = print_violations(violations, show_fix=args.fix)
|
||||
print("\n" + "=" * 60)
|
||||
print(f"\033[1;31mFound {total} violation(s) in {len(violations)} file(s)\033[0m")
|
||||
print("\nProhibited patterns:")
|
||||
print(" - models.TextChoices / models.IntegerChoices classes")
|
||||
print(" - Inline choices=[(value, label), ...]")
|
||||
print(" - References to InnerClass.choices")
|
||||
print("\nRequired pattern:")
|
||||
print(" RichChoiceField(choice_group='group_name', domain='domain_name')")
|
||||
print("\nSee: .agent/workflows/enforce-richchoice.md for migration guide")
|
||||
sys.exit(1)
|
||||
else:
|
||||
print("\n\033[1;32m✓ No prohibited choice patterns found!\033[0m")
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
107
uv.lock
generated
107
uv.lock
generated
@@ -837,7 +837,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "django-typer"
|
||||
version = "3.5.0"
|
||||
version = "3.5.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
@@ -845,9 +845,9 @@ dependencies = [
|
||||
{ name = "shellingham" },
|
||||
{ name = "typer-slim" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/29/08/08b7ed6d33dcc07538b522dea121fd65412cc5fb57716da1a58158a272ba/django_typer-3.5.0.tar.gz", hash = "sha256:48e1c0296979eae9e76d3bce6ed9bbbff0ca40fc0753eaa66f7826919416be9a", size = 3074197, upload-time = "2025-11-22T17:26:07.62Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3a/69/ad177aa9acd536f4b6f45a46cf25c4549e2f88169115492a87666e2c84ac/django_typer-3.5.1.tar.gz", hash = "sha256:9e9a6e9093b97fcb61c003a192f4398249830346c64f60276485f4c8b1e331f8", size = 3083716, upload-time = "2026-01-14T15:42:17.499Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/67/88/ed897a5d38be8b0dd6f9d9d482e86f7eeb48a5503250cbd217461c37e04d/django_typer-3.5.0-py3-none-any.whl", hash = "sha256:ffb0222f915bbdcfb24ef179aa374a3d7ab61454a4043dd4c7f97cb6a1b79950", size = 295611, upload-time = "2025-11-22T17:26:05.488Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/1d/891bffc8d93000f91e06d598bee625d7a47df6fd7c7908b746070d5660d6/django_typer-3.5.1-py3-none-any.whl", hash = "sha256:e8a68aa6f95bf40f8fff66a2af16d90e6a21c7963564c3aecb25a0e37c47033d", size = 295789, upload-time = "2026-01-14T15:42:15.804Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -919,26 +919,26 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "faker"
|
||||
version = "40.1.0"
|
||||
version = "40.1.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "tzdata" },
|
||||
{ name = "tzdata", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d7/1d/aa43ef59589ddf3647df918143f1bac9eb004cce1c43124ee3347061797d/faker-40.1.0.tar.gz", hash = "sha256:c402212a981a8a28615fea9120d789e3f6062c0c259a82bfb8dff5d273e539d2", size = 1948784, upload-time = "2025-12-29T18:06:00.659Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5e/77/1c3ff07b6739b9a1d23ca01ec0a90a309a33b78e345a3eb52f9ce9240e36/faker-40.1.2.tar.gz", hash = "sha256:b76a68163aa5f171d260fc24827a8349bc1db672f6a665359e8d0095e8135d30", size = 1949802, upload-time = "2026-01-13T20:51:49.917Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/23/e22da510e1ec1488966330bf76d8ff4bd535cbfc93660eeb7657761a1bb2/faker-40.1.0-py3-none-any.whl", hash = "sha256:a616d35818e2a2387c297de80e2288083bc915e24b7e39d2fb5bc66cce3a929f", size = 1985317, upload-time = "2025-12-29T18:05:58.831Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/ec/91a434c8a53d40c3598966621dea9c50512bec6ce8e76fa1751015e74cef/faker-40.1.2-py3-none-any.whl", hash = "sha256:93503165c165d330260e4379fd6dc07c94da90c611ed3191a0174d2ab9966a42", size = 1985633, upload-time = "2026-01-13T20:51:47.982Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fido2"
|
||||
version = "2.0.0"
|
||||
version = "2.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cryptography" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8d/b9/6ec8d8ec5715efc6ae39e8694bd48d57c189906f0628558f56688d0447b2/fido2-2.0.0.tar.gz", hash = "sha256:3061cd05e73b3a0ef6afc3b803d57c826aa2d6a9732d16abd7277361f58e7964", size = 274942, upload-time = "2025-05-20T09:45:00.974Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/43/f0/4f99ff79569a8f59e629bd4c042d5f160781dae58a818bb98c1127e4a6b4/fido2-2.1.0.tar.gz", hash = "sha256:b84da93f9bf608a675feb8445cc1d0cec2b8a02d46d165a1ba2fc8a1ab3bdce1", size = 4455878, upload-time = "2026-01-14T14:04:04.424Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/7d/a1dba174d7ec4b6b8d6360eed0ac3a4a4e2aa45f234e903592d3184c6c3f/fido2-2.0.0-py3-none-any.whl", hash = "sha256:685f54a50a57e019c6156e2dd699802a603e3abf70bab334f26affdd4fb8d4f7", size = 224761, upload-time = "2025-05-20T09:44:59.029Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/af/fa786b701282592747c4c52080e319e77a77be2cf2f2c685d4ebbfd6140a/fido2-2.1.0-py3-none-any.whl", hash = "sha256:1bcc68f5664c31a184eb7c54de4dbad3bdf3ed19705e565a778ec8fd1376658a", size = 226913, upload-time = "2026-01-14T14:03:59.086Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1284,11 +1284,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pathspec"
|
||||
version = "1.0.2"
|
||||
version = "1.0.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/41/b9/6eb731b52f132181a9144bbe77ff82117f6b2d2fbfba49aaab2c014c4760/pathspec-1.0.2.tar.gz", hash = "sha256:fa32b1eb775ed9ba8d599b22c5f906dc098113989da2c00bf8b210078ca7fb92", size = 130502, upload-time = "2026-01-08T04:33:27.613Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4c/b2/bb8e495d5262bfec41ab5cb18f522f1012933347fb5d9e62452d446baca2/pathspec-1.0.3.tar.gz", hash = "sha256:bac5cf97ae2c2876e2d25ebb15078eb04d76e4b98921ee31c6f85ade8b59444d", size = 130841, upload-time = "2026-01-09T15:46:46.009Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/78/6b/14fc9049d78435fd29e82846c777bd7ed9c470013dc8d0260fff3ff1c11e/pathspec-1.0.2-py3-none-any.whl", hash = "sha256:62f8558917908d237d399b9b338ef455a814801a4688bc41074b25feefd93472", size = 54844, upload-time = "2026-01-08T04:33:26.4Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/2b/121e912bd60eebd623f873fd090de0e84f322972ab25a7f9044c056804ed/pathspec-1.0.3-py3-none-any.whl", hash = "sha256:e80767021c1cc524aa3fb14bedda9c34406591343cc42797b386ce7b9354fb6c", size = 55021, upload-time = "2026-01-09T15:46:44.652Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1302,29 +1302,60 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pillow"
|
||||
version = "11.1.0"
|
||||
version = "12.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f3/af/c097e544e7bd278333db77933e535098c259609c4eb3b85381109602fb5b/pillow-11.1.0.tar.gz", hash = "sha256:368da70808b36d73b4b390a8ffac11069f8a5c85f29eff1f1b01bcf3ef5b2a20", size = 46742715, upload-time = "2025-01-02T08:13:58.407Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d0/02/d52c733a2452ef1ffcc123b68e6606d07276b0e358db70eabad7e40042b7/pillow-12.1.0.tar.gz", hash = "sha256:5c5ae0a06e9ea030ab786b0251b32c7e4ce10e58d983c0d5c56029455180b5b9", size = 46977283, upload-time = "2026-01-02T09:13:29.892Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/31/9ca79cafdce364fd5c980cd3416c20ce1bebd235b470d262f9d24d810184/pillow-11.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ae98e14432d458fc3de11a77ccb3ae65ddce70f730e7c76140653048c71bfcbc", size = 3226640, upload-time = "2025-01-02T08:11:58.329Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/0f/ff07ad45a1f172a497aa393b13a9d81a32e1477ef0e869d030e3c1532521/pillow-11.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cc1331b6d5a6e144aeb5e626f4375f5b7ae9934ba620c0ac6b3e43d5e683a0f0", size = 3101437, upload-time = "2025-01-02T08:12:01.797Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/2f/9906fca87a68d29ec4530be1f893149e0cb64a86d1f9f70a7cfcdfe8ae44/pillow-11.1.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:758e9d4ef15d3560214cddbc97b8ef3ef86ce04d62ddac17ad39ba87e89bd3b1", size = 4326605, upload-time = "2025-01-02T08:12:05.224Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/0f/f3547ee15b145bc5c8b336401b2d4c9d9da67da9dcb572d7c0d4103d2c69/pillow-11.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b523466b1a31d0dcef7c5be1f20b942919b62fd6e9a9be199d035509cbefc0ec", size = 4411173, upload-time = "2025-01-02T08:12:08.281Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/df/bf8176aa5db515c5de584c5e00df9bab0713548fd780c82a86cba2c2fedb/pillow-11.1.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:9044b5e4f7083f209c4e35aa5dd54b1dd5b112b108648f5c902ad586d4f945c5", size = 4369145, upload-time = "2025-01-02T08:12:11.411Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/7c/7433122d1cfadc740f577cb55526fdc39129a648ac65ce64db2eb7209277/pillow-11.1.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:3764d53e09cdedd91bee65c2527815d315c6b90d7b8b79759cc48d7bf5d4f114", size = 4496340, upload-time = "2025-01-02T08:12:15.29Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/46/dd94b93ca6bd555588835f2504bd90c00d5438fe131cf01cfa0c5131a19d/pillow-11.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:31eba6bbdd27dde97b0174ddf0297d7a9c3a507a8a1480e1e60ef914fe23d352", size = 4296906, upload-time = "2025-01-02T08:12:17.485Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/28/2f9d32014dfc7753e586db9add35b8a41b7a3b46540e965cb6d6bc607bd2/pillow-11.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b5d658fbd9f0d6eea113aea286b21d3cd4d3fd978157cbf2447a6035916506d3", size = 4431759, upload-time = "2025-01-02T08:12:20.382Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/48/19c2cbe7403870fbe8b7737d19eb013f46299cdfe4501573367f6396c775/pillow-11.1.0-cp313-cp313-win32.whl", hash = "sha256:f86d3a7a9af5d826744fabf4afd15b9dfef44fe69a98541f666f66fbb8d3fef9", size = 2291657, upload-time = "2025-01-02T08:12:23.922Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/ad/285c556747d34c399f332ba7c1a595ba245796ef3e22eae190f5364bb62b/pillow-11.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:593c5fd6be85da83656b93ffcccc2312d2d149d251e98588b14fbc288fd8909c", size = 2626304, upload-time = "2025-01-02T08:12:28.069Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/7b/ef35a71163bf36db06e9c8729608f78dedf032fc8313d19bd4be5c2588f3/pillow-11.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:11633d58b6ee5733bde153a8dafd25e505ea3d32e261accd388827ee987baf65", size = 2375117, upload-time = "2025-01-02T08:12:30.064Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/30/77f54228401e84d6791354888549b45824ab0ffde659bafa67956303a09f/pillow-11.1.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:70ca5ef3b3b1c4a0812b5c63c57c23b63e53bc38e758b37a951e5bc466449861", size = 3230060, upload-time = "2025-01-02T08:12:32.362Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/b1/56723b74b07dd64c1010fee011951ea9c35a43d8020acd03111f14298225/pillow-11.1.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8000376f139d4d38d6851eb149b321a52bb8893a88dae8ee7d95840431977081", size = 3106192, upload-time = "2025-01-02T08:12:34.361Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/cd/7bf7180e08f80a4dcc6b4c3a0aa9e0b0ae57168562726a05dc8aa8fa66b0/pillow-11.1.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ee85f0696a17dd28fbcfceb59f9510aa71934b483d1f5601d1030c3c8304f3c", size = 4446805, upload-time = "2025-01-02T08:12:36.99Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/42/87c856ea30c8ed97e8efbe672b58c8304dee0573f8c7cab62ae9e31db6ae/pillow-11.1.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:dd0e081319328928531df7a0e63621caf67652c8464303fd102141b785ef9547", size = 4530623, upload-time = "2025-01-02T08:12:41.912Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/41/026879e90c84a88e33fb00cc6bd915ac2743c67e87a18f80270dfe3c2041/pillow-11.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e63e4e5081de46517099dc30abe418122f54531a6ae2ebc8680bcd7096860eab", size = 4465191, upload-time = "2025-01-02T08:12:45.186Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/fb/a7960e838bc5df57a2ce23183bfd2290d97c33028b96bde332a9057834d3/pillow-11.1.0-cp313-cp313t-win32.whl", hash = "sha256:dda60aa465b861324e65a78c9f5cf0f4bc713e4309f83bc387be158b077963d9", size = 2295494, upload-time = "2025-01-02T08:12:47.098Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/6c/6ec83ee2f6f0fda8d4cf89045c6be4b0373ebfc363ba8538f8c999f63fcd/pillow-11.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ad5db5781c774ab9a9b2c4302bbf0c1014960a0a7be63278d13ae6fdf88126fe", size = 2631595, upload-time = "2025-01-02T08:12:50.47Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/6c/41c21c6c8af92b9fea313aa47c75de49e2f9a467964ee33eb0135d47eb64/pillow-11.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:67cd427c68926108778a9005f2a04adbd5e67c442ed21d95389fe1d595458756", size = 2377651, upload-time = "2025-01-02T08:12:53.356Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/c7/2530a4aa28248623e9d7f27316b42e27c32ec410f695929696f2e0e4a778/pillow-12.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7b5dd7cbae20285cdb597b10eb5a2c13aa9de6cde9bb64a3c1317427b1db1ae1", size = 4062543, upload-time = "2026-01-02T09:11:31.566Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/1f/40b8eae823dc1519b87d53c30ed9ef085506b05281d313031755c1705f73/pillow-12.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:29a4cef9cb672363926f0470afc516dbf7305a14d8c54f7abbb5c199cd8f8179", size = 4138373, upload-time = "2026-01-02T09:11:33.367Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/77/6fa60634cf06e52139fd0e89e5bbf055e8166c691c42fb162818b7fda31d/pillow-12.1.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:681088909d7e8fa9e31b9799aaa59ba5234c58e5e4f1951b4c4d1082a2e980e0", size = 3601241, upload-time = "2026-01-02T09:11:35.011Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/bf/28ab865de622e14b747f0cd7877510848252d950e43002e224fb1c9ababf/pillow-12.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:983976c2ab753166dc66d36af6e8ec15bb511e4a25856e2227e5f7e00a160587", size = 5262410, upload-time = "2026-01-02T09:11:36.682Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/34/583420a1b55e715937a85bd48c5c0991598247a1fd2eb5423188e765ea02/pillow-12.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:db44d5c160a90df2d24a24760bbd37607d53da0b34fb546c4c232af7192298ac", size = 4657312, upload-time = "2026-01-02T09:11:38.535Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/fd/f5a0896839762885b3376ff04878f86ab2b097c2f9a9cdccf4eda8ba8dc0/pillow-12.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6b7a9d1db5dad90e2991645874f708e87d9a3c370c243c2d7684d28f7e133e6b", size = 6232605, upload-time = "2026-01-02T09:11:40.602Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/aa/938a09d127ac1e70e6ed467bd03834350b33ef646b31edb7452d5de43792/pillow-12.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6258f3260986990ba2fa8a874f8b6e808cf5abb51a94015ca3dc3c68aa4f30ea", size = 8041617, upload-time = "2026-01-02T09:11:42.721Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/e8/538b24cb426ac0186e03f80f78bc8dc7246c667f58b540bdd57c71c9f79d/pillow-12.1.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e115c15e3bc727b1ca3e641a909f77f8ca72a64fff150f666fcc85e57701c26c", size = 6346509, upload-time = "2026-01-02T09:11:44.955Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/9a/632e58ec89a32738cabfd9ec418f0e9898a2b4719afc581f07c04a05e3c9/pillow-12.1.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6741e6f3074a35e47c77b23a4e4f2d90db3ed905cb1c5e6e0d49bff2045632bc", size = 7038117, upload-time = "2026-01-02T09:11:46.736Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/a2/d40308cf86eada842ca1f3ffa45d0ca0df7e4ab33c83f81e73f5eaed136d/pillow-12.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:935b9d1aed48fcfb3f838caac506f38e29621b44ccc4f8a64d575cb1b2a88644", size = 6460151, upload-time = "2026-01-02T09:11:48.625Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/88/f5b058ad6453a085c5266660a1417bdad590199da1b32fb4efcff9d33b05/pillow-12.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5fee4c04aad8932da9f8f710af2c1a15a83582cfb884152a9caa79d4efcdbf9c", size = 7164534, upload-time = "2026-01-02T09:11:50.445Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/ce/c17334caea1db789163b5d855a5735e47995b0b5dc8745e9a3605d5f24c0/pillow-12.1.0-cp313-cp313-win32.whl", hash = "sha256:a786bf667724d84aa29b5db1c61b7bfdde380202aaca12c3461afd6b71743171", size = 6332551, upload-time = "2026-01-02T09:11:52.234Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/07/74a9d941fa45c90a0d9465098fe1ec85de3e2afbdc15cc4766622d516056/pillow-12.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:461f9dfdafa394c59cd6d818bdfdbab4028b83b02caadaff0ffd433faf4c9a7a", size = 7040087, upload-time = "2026-01-02T09:11:54.822Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/09/c99950c075a0e9053d8e880595926302575bc742b1b47fe1bbcc8d388d50/pillow-12.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:9212d6b86917a2300669511ed094a9406888362e085f2431a7da985a6b124f45", size = 2452470, upload-time = "2026-01-02T09:11:56.522Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/ba/970b7d85ba01f348dee4d65412476321d40ee04dcb51cd3735b9dc94eb58/pillow-12.1.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:00162e9ca6d22b7c3ee8e61faa3c3253cd19b6a37f126cad04f2f88b306f557d", size = 5264816, upload-time = "2026-01-02T09:11:58.227Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/60/650f2fb55fdba7a510d836202aa52f0baac633e50ab1cf18415d332188fb/pillow-12.1.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:7d6daa89a00b58c37cb1747ec9fb7ac3bc5ffd5949f5888657dfddde6d1312e0", size = 4660472, upload-time = "2026-01-02T09:12:00.798Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/c0/5273a99478956a099d533c4f46cbaa19fd69d606624f4334b85e50987a08/pillow-12.1.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e2479c7f02f9d505682dc47df8c0ea1fc5e264c4d1629a5d63fe3e2334b89554", size = 6268974, upload-time = "2026-01-02T09:12:02.572Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/26/0bf714bc2e73d5267887d47931d53c4ceeceea6978148ed2ab2a4e6463c4/pillow-12.1.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f188d580bd870cda1e15183790d1cc2fa78f666e76077d103edf048eed9c356e", size = 8073070, upload-time = "2026-01-02T09:12:04.75Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/cf/1ea826200de111a9d65724c54f927f3111dc5ae297f294b370a670c17786/pillow-12.1.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0fde7ec5538ab5095cc02df38ee99b0443ff0e1c847a045554cf5f9af1f4aa82", size = 6380176, upload-time = "2026-01-02T09:12:06.626Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/e0/7938dd2b2013373fd85d96e0f38d62b7a5a262af21ac274250c7ca7847c9/pillow-12.1.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ed07dca4a8464bada6139ab38f5382f83e5f111698caf3191cb8dbf27d908b4", size = 7067061, upload-time = "2026-01-02T09:12:08.624Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/ad/a2aa97d37272a929a98437a8c0ac37b3cf012f4f8721e1bd5154699b2518/pillow-12.1.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f45bd71d1fa5e5749587613037b172e0b3b23159d1c00ef2fc920da6f470e6f0", size = 6491824, upload-time = "2026-01-02T09:12:10.488Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/44/80e46611b288d51b115826f136fb3465653c28f491068a72d3da49b54cd4/pillow-12.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:277518bf4fe74aa91489e1b20577473b19ee70fb97c374aa50830b279f25841b", size = 7190911, upload-time = "2026-01-02T09:12:12.772Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/77/eacc62356b4cf81abe99ff9dbc7402750044aed02cfd6a503f7c6fc11f3e/pillow-12.1.0-cp313-cp313t-win32.whl", hash = "sha256:7315f9137087c4e0ee73a761b163fc9aa3b19f5f606a7fc08d83fd3e4379af65", size = 6336445, upload-time = "2026-01-02T09:12:14.775Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/3c/57d81d0b74d218706dafccb87a87ea44262c43eef98eb3b164fd000e0491/pillow-12.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:0ddedfaa8b5f0b4ffbc2fa87b556dc59f6bb4ecb14a53b33f9189713ae8053c0", size = 7045354, upload-time = "2026-01-02T09:12:16.599Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/82/8b9b97bba2e3576a340f93b044a3a3a09841170ab4c1eb0d5c93469fd32f/pillow-12.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:80941e6d573197a0c28f394753de529bb436b1ca990ed6e765cf42426abc39f8", size = 2454547, upload-time = "2026-01-02T09:12:18.704Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/87/bdf971d8bbcf80a348cc3bacfcb239f5882100fe80534b0ce67a784181d8/pillow-12.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:5cb7bc1966d031aec37ddb9dcf15c2da5b2e9f7cc3ca7c54473a20a927e1eb91", size = 4062533, upload-time = "2026-01-02T09:12:20.791Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/4f/5eb37a681c68d605eb7034c004875c81f86ec9ef51f5be4a63eadd58859a/pillow-12.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:97e9993d5ed946aba26baf9c1e8cf18adbab584b99f452ee72f7ee8acb882796", size = 4138546, upload-time = "2026-01-02T09:12:23.664Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/6d/19a95acb2edbace40dcd582d077b991646b7083c41b98da4ed7555b59733/pillow-12.1.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:414b9a78e14ffeb98128863314e62c3f24b8a86081066625700b7985b3f529bd", size = 3601163, upload-time = "2026-01-02T09:12:26.338Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/36/2b8138e51cb42e4cc39c3297713455548be855a50558c3ac2beebdc251dd/pillow-12.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e6bdb408f7c9dd2a5ff2b14a3b0bb6d4deb29fb9961e6eb3ae2031ae9a5cec13", size = 5266086, upload-time = "2026-01-02T09:12:28.782Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/4b/649056e4d22e1caa90816bf99cef0884aed607ed38075bd75f091a607a38/pillow-12.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3413c2ae377550f5487991d444428f1a8ae92784aac79caa8b1e3b89b175f77e", size = 4657344, upload-time = "2026-01-02T09:12:31.117Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/6b/c5742cea0f1ade0cd61485dc3d81f05261fc2276f537fbdc00802de56779/pillow-12.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e5dcbe95016e88437ecf33544ba5db21ef1b8dd6e1b434a2cb2a3d605299e643", size = 6232114, upload-time = "2026-01-02T09:12:32.936Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/8f/9f521268ce22d63991601aafd3d48d5ff7280a246a1ef62d626d67b44064/pillow-12.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d0a7735df32ccbcc98b98a1ac785cc4b19b580be1bdf0aeb5c03223220ea09d5", size = 8042708, upload-time = "2026-01-02T09:12:34.78Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/eb/257f38542893f021502a1bbe0c2e883c90b5cff26cc33b1584a841a06d30/pillow-12.1.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c27407a2d1b96774cbc4a7594129cc027339fd800cd081e44497722ea1179de", size = 6347762, upload-time = "2026-01-02T09:12:36.748Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/5a/8ba375025701c09b309e8d5163c5a4ce0102fa86bbf8800eb0d7ac87bc51/pillow-12.1.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15c794d74303828eaa957ff8070846d0efe8c630901a1c753fdc63850e19ecd9", size = 7039265, upload-time = "2026-01-02T09:12:39.082Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/dc/cf5e4cdb3db533f539e88a7bbf9f190c64ab8a08a9bc7a4ccf55067872e4/pillow-12.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c990547452ee2800d8506c4150280757f88532f3de2a58e3022e9b179107862a", size = 6462341, upload-time = "2026-01-02T09:12:40.946Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/47/0291a25ac9550677e22eda48510cfc4fa4b2ef0396448b7fbdc0a6946309/pillow-12.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b63e13dd27da389ed9475b3d28510f0f954bca0041e8e551b2a4eb1eab56a39a", size = 7165395, upload-time = "2026-01-02T09:12:42.706Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/4c/e005a59393ec4d9416be06e6b45820403bb946a778e39ecec62f5b2b991e/pillow-12.1.0-cp314-cp314-win32.whl", hash = "sha256:1a949604f73eb07a8adab38c4fe50791f9919344398bdc8ac6b307f755fc7030", size = 6431413, upload-time = "2026-01-02T09:12:44.944Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/af/f23697f587ac5f9095d67e31b81c95c0249cd461a9798a061ed6709b09b5/pillow-12.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:4f9f6a650743f0ddee5593ac9e954ba1bdbc5e150bc066586d4f26127853ab94", size = 7176779, upload-time = "2026-01-02T09:12:46.727Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/36/6a51abf8599232f3e9afbd16d52829376a68909fe14efe29084445db4b73/pillow-12.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:808b99604f7873c800c4840f55ff389936ef1948e4e87645eaf3fccbc8477ac4", size = 2543105, upload-time = "2026-01-02T09:12:49.243Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/54/2e1dd20c8749ff225080d6ba465a0cab4387f5db0d1c5fb1439e2d99923f/pillow-12.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bc11908616c8a283cf7d664f77411a5ed2a02009b0097ff8abbba5e79128ccf2", size = 5268571, upload-time = "2026-01-02T09:12:51.11Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/61/571163a5ef86ec0cf30d265ac2a70ae6fc9e28413d1dc94fa37fae6bda89/pillow-12.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:896866d2d436563fa2a43a9d72f417874f16b5545955c54a64941e87c1376c61", size = 4660426, upload-time = "2026-01-02T09:12:52.865Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/e1/53ee5163f794aef1bf84243f755ee6897a92c708505350dd1923f4afec48/pillow-12.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8e178e3e99d3c0ea8fc64b88447f7cac8ccf058af422a6cedc690d0eadd98c51", size = 6269908, upload-time = "2026-01-02T09:12:54.884Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/0b/b4b4106ff0ee1afa1dc599fde6ab230417f800279745124f6c50bcffed8e/pillow-12.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:079af2fb0c599c2ec144ba2c02766d1b55498e373b3ac64687e43849fbbef5bc", size = 8074733, upload-time = "2026-01-02T09:12:56.802Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/9f/80b411cbac4a732439e629a26ad3ef11907a8c7fc5377b7602f04f6fe4e7/pillow-12.1.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bdec5e43377761c5dbca620efb69a77f6855c5a379e32ac5b158f54c84212b14", size = 6381431, upload-time = "2026-01-02T09:12:58.823Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/b7/d65c45db463b66ecb6abc17c6ba6917a911202a07662247e1355ce1789e7/pillow-12.1.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:565c986f4b45c020f5421a4cea13ef294dde9509a8577f29b2fc5edc7587fff8", size = 7068529, upload-time = "2026-01-02T09:13:00.885Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/96/dfd4cd726b4a45ae6e3c669fc9e49deb2241312605d33aba50499e9d9bd1/pillow-12.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:43aca0a55ce1eefc0aefa6253661cb54571857b1a7b2964bd8a1e3ef4b729924", size = 6492981, upload-time = "2026-01-02T09:13:03.314Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/1c/b5dc52cf713ae46033359c5ca920444f18a6359ce1020dd3e9c553ea5bc6/pillow-12.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0deedf2ea233722476b3a81e8cdfbad786f7adbed5d848469fa59fe52396e4ef", size = 7191878, upload-time = "2026-01-02T09:13:05.276Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/26/c4188248bd5edaf543864fe4834aebe9c9cb4968b6f573ce014cc42d0720/pillow-12.1.0-cp314-cp314t-win32.whl", hash = "sha256:b17fbdbe01c196e7e159aacb889e091f28e61020a8abeac07b68079b6e626988", size = 6438703, upload-time = "2026-01-02T09:13:07.491Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/0e/69ed296de8ea05cb03ee139cee600f424ca166e632567b2d66727f08c7ed/pillow-12.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27b9baecb428899db6c0de572d6d305cfaf38ca1596b5c0542a5182e3e74e8c6", size = 7182927, upload-time = "2026-01-02T09:13:09.841Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/f5/68334c015eed9b5cff77814258717dec591ded209ab5b6fb70e2ae873d1d/pillow-12.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:f61333d817698bdcdd0f9d7793e365ac3d2a21c1f1eb02b32ad6aefb8d8ea831", size = 2545104, upload-time = "2026-01-02T09:13:12.068Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2167,7 +2198,7 @@ requires-dist = [
|
||||
{ name = "httpx", specifier = ">=0.28.1" },
|
||||
{ name = "nplusone", specifier = ">=1.0.0" },
|
||||
{ name = "piexif", specifier = ">=1.1.3" },
|
||||
{ name = "pillow", specifier = ">=10.4.0,<11.2" },
|
||||
{ name = "pillow", specifier = ">=12.0" },
|
||||
{ name = "psutil", specifier = ">=7.0.0" },
|
||||
{ name = "psycopg2-binary", specifier = ">=2.9.9" },
|
||||
{ name = "pycountry", specifier = ">=24.6.1" },
|
||||
@@ -2255,15 +2286,15 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "typer-slim"
|
||||
version = "0.20.1"
|
||||
version = "0.21.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3f/3d/6a4ec47010e8de34dade20c8e7bce90502b173f62a6b41619523a3fcf562/typer_slim-0.20.1.tar.gz", hash = "sha256:bb9e4f7e6dc31551c8a201383df322b81b0ce37239a5ead302598a2ebb6f7c9c", size = 106113, upload-time = "2025-12-19T16:48:54.206Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/17/d4/064570dec6358aa9049d4708e4a10407d74c99258f8b2136bb8702303f1a/typer_slim-0.21.1.tar.gz", hash = "sha256:73495dd08c2d0940d611c5a8c04e91c2a0a98600cbd4ee19192255a233b6dbfd", size = 110478, upload-time = "2026-01-06T11:21:11.176Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/f9/a273c8b57c69ac1b90509ebda204972265fdc978fbbecc25980786f8c038/typer_slim-0.20.1-py3-none-any.whl", hash = "sha256:8e89c5dbaffe87a4f86f4c7a9e2f7059b5b68c66f558f298969d42ce34f10122", size = 47440, upload-time = "2025-12-19T16:48:52.678Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/0a/4aca634faf693e33004796b6cee0ae2e1dba375a800c16ab8d3eff4bb800/typer_slim-0.21.1-py3-none-any.whl", hash = "sha256:6e6c31047f171ac93cc5a973c9e617dbc5ab2bddc4d0a3135dc161b4e2020e0d", size = 47444, upload-time = "2026-01-06T11:21:12.441Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Reference in New Issue
Block a user