feat: Add analytics, incident, and alert models and APIs, along with user permissions and bulk profile lookups.

This commit is contained in:
pacnpal
2026-01-07 11:07:36 -05:00
parent 4da7e52fb0
commit 3ec5a4857d
46 changed files with 4012 additions and 199 deletions

View File

@@ -0,0 +1,41 @@
# Generated by Django 5.2.9 on 2026-01-06 17:43
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
('support', '0002_add_category_to_ticket'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Report',
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)),
('object_id', models.CharField(help_text='ID of the entity being reported', max_length=50)),
('report_type', models.CharField(choices=[('inaccurate', 'Inaccurate Information'), ('inappropriate', 'Inappropriate Content'), ('spam', 'Spam'), ('copyright', 'Copyright Violation'), ('duplicate', 'Duplicate Content'), ('other', 'Other')], db_index=True, help_text='Type of issue being reported', max_length=20)),
('reason', models.TextField(help_text='Detailed description of the issue')),
('status', models.CharField(choices=[('pending', 'Pending'), ('investigating', 'Investigating'), ('resolved', 'Resolved'), ('dismissed', 'Dismissed')], db_index=True, default='pending', help_text='Current status of the report', max_length=20)),
('resolved_at', models.DateTimeField(blank=True, help_text='When the report was resolved', null=True)),
('resolution_notes', models.TextField(blank=True, help_text='Notes about how the report was resolved')),
('content_type', models.ForeignKey(help_text='Type of entity being reported', on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')),
('reporter', models.ForeignKey(blank=True, help_text='User who submitted the report', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='submitted_reports', to=settings.AUTH_USER_MODEL)),
('resolved_by', models.ForeignKey(blank=True, help_text='Moderator who resolved the report', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='resolved_reports', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Report',
'verbose_name_plural': 'Reports',
'ordering': ['-created_at'],
'abstract': False,
'indexes': [models.Index(fields=['status', 'created_at'], name='support_rep_status_aea90b_idx'), models.Index(fields=['content_type', 'object_id'], name='support_rep_content_e9be3b_idx'), models.Index(fields=['report_type', 'created_at'], name='support_rep_report__a54360_idx')],
},
),
]

View File

@@ -66,3 +66,105 @@ class Ticket(TrackedModel):
if self.user and not self.email:
self.email = self.user.email
super().save(*args, **kwargs)
class Report(TrackedModel):
"""
User-submitted reports about content issues.
Reports allow users to flag problems with specific entities
(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,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="submitted_reports",
help_text="User who submitted the report",
)
# Target entity using GenericForeignKey
content_type = models.ForeignKey(
"contenttypes.ContentType",
on_delete=models.CASCADE,
help_text="Type of entity being reported",
)
object_id = models.CharField(
max_length=50,
help_text="ID of the entity being reported",
)
# Note: GenericForeignKey doesn't create a database column
# It's a convenience for accessing the related object
# content_object = GenericForeignKey("content_type", "object_id")
# Report details
report_type = models.CharField(
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(
max_length=20,
choices=Status.choices,
default=Status.PENDING,
db_index=True,
help_text="Current status of the report",
)
# Resolution
resolved_at = models.DateTimeField(
null=True,
blank=True,
help_text="When the report was resolved",
)
resolved_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="resolved_reports",
help_text="Moderator who resolved the report",
)
resolution_notes = models.TextField(
blank=True,
help_text="Notes about how the report was resolved",
)
class Meta(TrackedModel.Meta):
verbose_name = "Report"
verbose_name_plural = "Reports"
ordering = ["-created_at"]
indexes = [
models.Index(fields=["status", "created_at"]),
models.Index(fields=["content_type", "object_id"]),
models.Index(fields=["report_type", "created_at"]),
]
def __str__(self):
return f"[{self.get_report_type_display()}] {self.content_type} #{self.object_id}"
@property
def is_resolved(self) -> bool:
return self.status in (self.Status.RESOLVED, self.Status.DISMISSED)

View File

@@ -33,3 +33,110 @@ class TicketSerializer(serializers.ModelSerializer):
if request and not request.user.is_authenticated and not data.get("email"):
raise serializers.ValidationError({"email": "Email is required for guests."})
return data
class ReportSerializer(serializers.ModelSerializer):
"""Serializer for Report model."""
reporter_username = serializers.CharField(source="reporter.username", read_only=True, allow_null=True)
resolved_by_username = serializers.CharField(source="resolved_by.username", read_only=True, allow_null=True)
report_type_display = serializers.CharField(source="get_report_type_display", read_only=True)
status_display = serializers.CharField(source="get_status_display", read_only=True)
content_type_name = serializers.CharField(source="content_type.model", read_only=True)
is_resolved = serializers.BooleanField(read_only=True)
class Meta:
from .models import Report
model = Report
fields = [
"id",
"reporter",
"reporter_username",
"content_type",
"content_type_name",
"object_id",
"report_type",
"report_type_display",
"reason",
"status",
"status_display",
"resolved_at",
"resolved_by",
"resolved_by_username",
"resolution_notes",
"is_resolved",
"created_at",
"updated_at",
]
read_only_fields = [
"id",
"reporter",
"resolved_at",
"resolved_by",
"created_at",
"updated_at",
]
class ReportCreateSerializer(serializers.ModelSerializer):
"""Serializer for creating reports with entity type as string."""
entity_type = serializers.CharField(write_only=True, help_text="Type of entity: park, ride, review, etc.")
entity_id = serializers.CharField(write_only=True, help_text="ID of the entity being reported")
class Meta:
from .models import Report
model = Report
fields = [
"entity_type",
"entity_id",
"report_type",
"reason",
]
def validate(self, data):
from django.contrib.contenttypes.models import ContentType
entity_type = data.pop("entity_type")
entity_id = data.pop("entity_id")
# Map common entity types to app.model
type_mapping = {
"park": ("parks", "park"),
"ride": ("rides", "ride"),
"review": ("reviews", "review"),
"user": ("accounts", "user"),
}
if entity_type in type_mapping:
app_label, model_name = type_mapping[entity_type]
else:
# Try to parse as app.model
parts = entity_type.split(".")
if len(parts) != 2:
raise serializers.ValidationError(
{"entity_type": f"Unknown entity type: {entity_type}. Use 'park', 'ride', 'review', or 'app.model'."}
)
app_label, model_name = parts
try:
content_type = ContentType.objects.get(app_label=app_label, model=model_name)
except ContentType.DoesNotExist:
raise serializers.ValidationError({"entity_type": f"Unknown entity type: {entity_type}"})
data["content_type"] = content_type
data["object_id"] = entity_id
return data
class ReportResolveSerializer(serializers.Serializer):
"""Serializer for resolving reports."""
status = serializers.ChoiceField(
choices=[("resolved", "Resolved"), ("dismissed", "Dismissed")],
default="resolved",
)
notes = serializers.CharField(required=False, allow_blank=True)

View File

@@ -1,11 +1,13 @@
from django.urls import include, path
from rest_framework.routers import DefaultRouter
from .views import TicketViewSet
from .views import ReportViewSet, TicketViewSet
router = DefaultRouter()
router.register(r"tickets", TicketViewSet, basename="ticket")
router.register(r"reports", ReportViewSet, basename="report")
urlpatterns = [
path("", include(router.urls)),
]

View File

@@ -1,8 +1,16 @@
from django.utils import timezone
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import filters, permissions, viewsets
from rest_framework import filters, permissions, status, viewsets
from rest_framework.decorators import action
from rest_framework.response import Response
from .models import Ticket
from .serializers import TicketSerializer
from .models import Report, Ticket
from .serializers import (
ReportCreateSerializer,
ReportResolveSerializer,
ReportSerializer,
TicketSerializer,
)
class TicketViewSet(viewsets.ModelViewSet):
@@ -33,3 +41,61 @@ class TicketViewSet(viewsets.ModelViewSet):
serializer.save(user=self.request.user, email=self.request.user.email)
else:
serializer.save()
class ReportViewSet(viewsets.ModelViewSet):
"""
ViewSet for handling user-submitted content reports.
- Authenticated users can CREATE reports
- Staff can LIST/RETRIEVE all reports
- Users can LIST/RETRIEVE their own reports
- Staff can RESOLVE reports
"""
queryset = Report.objects.select_related("reporter", "resolved_by", "content_type").all()
permission_classes = [permissions.IsAuthenticated]
filter_backends = [DjangoFilterBackend, filters.OrderingFilter, filters.SearchFilter]
filterset_fields = ["status", "report_type"]
search_fields = ["reason", "resolution_notes"]
ordering_fields = ["created_at", "status", "report_type"]
ordering = ["-created_at"]
def get_serializer_class(self):
if self.action == "create":
return ReportCreateSerializer
if self.action == "resolve":
return ReportResolveSerializer
return ReportSerializer
def get_queryset(self):
user = self.request.user
if user.is_staff:
return Report.objects.select_related("reporter", "resolved_by", "content_type").all()
return Report.objects.select_related("reporter", "resolved_by", "content_type").filter(reporter=user)
def perform_create(self, serializer):
serializer.save(reporter=self.request.user)
@action(detail=True, methods=["post"], permission_classes=[permissions.IsAdminUser])
def resolve(self, request, pk=None):
"""Mark a report as resolved or dismissed."""
report = self.get_object()
if report.is_resolved:
return Response(
{"detail": "Report is already resolved"},
status=status.HTTP_400_BAD_REQUEST,
)
serializer = ReportResolveSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
report.status = serializer.validated_data.get("status", "resolved")
report.resolved_at = timezone.now()
report.resolved_by = request.user
report.resolution_notes = serializer.validated_data.get("notes", "")
report.save()
return Response(ReportSerializer(report).data)