mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2026-02-05 06:05:18 -05:00
feat: Add analytics, incident, and alert models and APIs, along with user permissions and bulk profile lookups.
This commit is contained in:
@@ -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')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)),
|
||||
]
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user