This commit is contained in:
pacnpal
2026-01-08 13:44:37 -05:00
parent 40cba5bdb2
commit fe960e8b62
21 changed files with 2008 additions and 59 deletions

View File

@@ -0,0 +1,93 @@
"""
Milestone serializers for timeline events.
"""
from rest_framework import serializers
from apps.core.models import Milestone
class MilestoneSerializer(serializers.ModelSerializer):
"""Serializer for Milestone model matching frontend milestoneValidationSchema."""
class Meta:
model = Milestone
fields = [
"id",
"title",
"description",
"event_type",
"event_date",
"event_date_precision",
"entity_type",
"entity_id",
"is_public",
"display_order",
"from_value",
"to_value",
"from_entity_id",
"to_entity_id",
"from_location_id",
"to_location_id",
"created_at",
"updated_at",
]
read_only_fields = ["id", "created_at", "updated_at"]
class MilestoneCreateSerializer(serializers.ModelSerializer):
"""Serializer for creating milestones."""
class Meta:
model = Milestone
fields = [
"title",
"description",
"event_type",
"event_date",
"event_date_precision",
"entity_type",
"entity_id",
"is_public",
"display_order",
"from_value",
"to_value",
"from_entity_id",
"to_entity_id",
"from_location_id",
"to_location_id",
]
def validate(self, attrs):
"""Validate change events have from/to values."""
change_events = ["name_change", "operator_change", "owner_change", "location_change", "status_change"]
if attrs.get("event_type") in change_events:
has_change_data = (
attrs.get("from_value")
or attrs.get("to_value")
or attrs.get("from_entity_id")
or attrs.get("to_entity_id")
or attrs.get("from_location_id")
or attrs.get("to_location_id")
)
if not has_change_data:
raise serializers.ValidationError(
"Change events must specify what changed (from/to values or entity IDs)"
)
return attrs
class MilestoneListSerializer(serializers.ModelSerializer):
"""Lightweight serializer for listing milestones."""
class Meta:
model = Milestone
fields = [
"id",
"title",
"event_type",
"event_date",
"entity_type",
"entity_id",
"is_public",
]

View File

@@ -0,0 +1,79 @@
"""
Milestone views for timeline events.
"""
from django_filters import rest_framework as filters
from rest_framework import status, viewsets
from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated, IsAuthenticatedOrReadOnly
from rest_framework.response import Response
from apps.core.models import Milestone
from .milestone_serializers import (
MilestoneCreateSerializer,
MilestoneListSerializer,
MilestoneSerializer,
)
class MilestoneFilter(filters.FilterSet):
"""Filters for milestone listing."""
entity_type = filters.CharFilter(field_name="entity_type")
entity_id = filters.UUIDFilter(field_name="entity_id")
event_type = filters.CharFilter(field_name="event_type")
is_public = filters.BooleanFilter(field_name="is_public")
event_date_after = filters.DateFilter(field_name="event_date", lookup_expr="gte")
event_date_before = filters.DateFilter(field_name="event_date", lookup_expr="lte")
class Meta:
model = Milestone
fields = ["entity_type", "entity_id", "event_type", "is_public"]
class MilestoneViewSet(viewsets.ModelViewSet):
"""
ViewSet for managing milestones/timeline events.
Supports filtering by entity_type, entity_id, event_type, and date range.
"""
queryset = Milestone.objects.all()
filterset_class = MilestoneFilter
permission_classes = [IsAuthenticatedOrReadOnly]
def get_serializer_class(self):
if self.action == "list":
return MilestoneListSerializer
if self.action == "create":
return MilestoneCreateSerializer
return MilestoneSerializer
def get_queryset(self):
"""Filter queryset based on visibility."""
queryset = super().get_queryset()
# Non-authenticated users only see public milestones
if not self.request.user.is_authenticated:
queryset = queryset.filter(is_public=True)
return queryset.order_by("-event_date", "display_order")
@action(detail=False, methods=["get"], url_path="entity/(?P<entity_type>[^/]+)/(?P<entity_id>[^/]+)")
def by_entity(self, request, entity_type=None, entity_id=None):
"""Get all milestones for a specific entity."""
queryset = self.get_queryset().filter(
entity_type=entity_type,
entity_id=entity_id,
)
serializer = MilestoneListSerializer(queryset, many=True)
return Response(serializer.data)
@action(detail=False, methods=["get"], url_path="timeline")
def timeline(self, request):
"""Get a unified timeline view of recent milestones across all entities."""
limit = int(request.query_params.get("limit", 50))
queryset = self.get_queryset()[:limit]
serializer = MilestoneListSerializer(queryset, many=True)
return Response(serializer.data)

View File

@@ -0,0 +1,94 @@
# Generated by Django 5.2.9 on 2026-01-08 17:59
import django.db.models.deletion
import pgtrigger.compiler
import pgtrigger.migrations
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0009_pageview_pageviewevent_and_more'),
('pghistory', '0007_auto_20250421_0444'),
]
operations = [
migrations.CreateModel(
name='MilestoneEvent',
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()),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('title', models.CharField(help_text='Title or name of the event', max_length=200)),
('description', models.TextField(blank=True, help_text='Detailed description of the event')),
('event_type', models.CharField(help_text="Type of event (e.g., 'opening', 'closing', 'name_change', 'status_change')", max_length=50)),
('event_date', models.DateField(help_text='Date when the event occurred or will occur')),
('event_date_precision', models.CharField(choices=[('exact', 'Exact Date'), ('month', 'Month and Year'), ('year', 'Year Only'), ('decade', 'Decade'), ('century', 'Century'), ('approximate', 'Approximate')], default='exact', help_text='Precision of the event date', max_length=20)),
('entity_type', models.CharField(help_text="Type of entity (e.g., 'park', 'ride', 'company')", max_length=50)),
('entity_id', models.UUIDField(help_text='UUID of the associated entity')),
('is_public', models.BooleanField(default=True, help_text='Whether this milestone is publicly visible')),
('display_order', models.IntegerField(default=0, help_text='Order for displaying multiple milestones on the same date')),
('from_value', models.CharField(blank=True, help_text='Previous value (for change events)', max_length=200)),
('to_value', models.CharField(blank=True, help_text='New value (for change events)', max_length=200)),
('from_entity_id', models.UUIDField(blank=True, help_text='Previous entity reference (e.g., old operator)', null=True)),
('to_entity_id', models.UUIDField(blank=True, help_text='New entity reference (e.g., new operator)', null=True)),
('from_location_id', models.UUIDField(blank=True, help_text='Previous location reference (for relocations)', null=True)),
('to_location_id', models.UUIDField(blank=True, help_text='New location reference (for relocations)', null=True)),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='Milestone',
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)),
('title', models.CharField(help_text='Title or name of the event', max_length=200)),
('description', models.TextField(blank=True, help_text='Detailed description of the event')),
('event_type', models.CharField(db_index=True, help_text="Type of event (e.g., 'opening', 'closing', 'name_change', 'status_change')", max_length=50)),
('event_date', models.DateField(db_index=True, help_text='Date when the event occurred or will occur')),
('event_date_precision', models.CharField(choices=[('exact', 'Exact Date'), ('month', 'Month and Year'), ('year', 'Year Only'), ('decade', 'Decade'), ('century', 'Century'), ('approximate', 'Approximate')], default='exact', help_text='Precision of the event date', max_length=20)),
('entity_type', models.CharField(db_index=True, help_text="Type of entity (e.g., 'park', 'ride', 'company')", max_length=50)),
('entity_id', models.UUIDField(db_index=True, help_text='UUID of the associated entity')),
('is_public', models.BooleanField(default=True, help_text='Whether this milestone is publicly visible')),
('display_order', models.IntegerField(default=0, help_text='Order for displaying multiple milestones on the same date')),
('from_value', models.CharField(blank=True, help_text='Previous value (for change events)', max_length=200)),
('to_value', models.CharField(blank=True, help_text='New value (for change events)', max_length=200)),
('from_entity_id', models.UUIDField(blank=True, help_text='Previous entity reference (e.g., old operator)', null=True)),
('to_entity_id', models.UUIDField(blank=True, help_text='New entity reference (e.g., new operator)', null=True)),
('from_location_id', models.UUIDField(blank=True, help_text='Previous location reference (for relocations)', null=True)),
('to_location_id', models.UUIDField(blank=True, help_text='New location reference (for relocations)', null=True)),
],
options={
'verbose_name': 'Milestone',
'verbose_name_plural': 'Milestones',
'ordering': ['-event_date', 'display_order'],
'abstract': False,
'indexes': [models.Index(fields=['entity_type', 'entity_id'], name='core_milest_entity__effdde_idx'), models.Index(fields=['event_type', 'event_date'], name='core_milest_event_t_0070b8_idx'), models.Index(fields=['is_public', 'event_date'], name='core_milest_is_publ_2ce98c_idx')],
},
),
pgtrigger.migrations.AddTrigger(
model_name='milestone',
trigger=pgtrigger.compiler.Trigger(name='insert_insert', sql=pgtrigger.compiler.UpsertTriggerSql(func='INSERT INTO "core_milestoneevent" ("created_at", "description", "display_order", "entity_id", "entity_type", "event_date", "event_date_precision", "event_type", "from_entity_id", "from_location_id", "from_value", "id", "is_public", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "title", "to_entity_id", "to_location_id", "to_value", "updated_at") VALUES (NEW."created_at", NEW."description", NEW."display_order", NEW."entity_id", NEW."entity_type", NEW."event_date", NEW."event_date_precision", NEW."event_type", NEW."from_entity_id", NEW."from_location_id", NEW."from_value", NEW."id", NEW."is_public", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."title", NEW."to_entity_id", NEW."to_location_id", NEW."to_value", NEW."updated_at"); RETURN NULL;', hash='6c4386ed0356cf9a3db65c829163401409e79622', operation='INSERT', pgid='pgtrigger_insert_insert_52c81', table='core_milestone', when='AFTER')),
),
pgtrigger.migrations.AddTrigger(
model_name='milestone',
trigger=pgtrigger.compiler.Trigger(name='update_update', sql=pgtrigger.compiler.UpsertTriggerSql(condition='WHEN (OLD.* IS DISTINCT FROM NEW.*)', func='INSERT INTO "core_milestoneevent" ("created_at", "description", "display_order", "entity_id", "entity_type", "event_date", "event_date_precision", "event_type", "from_entity_id", "from_location_id", "from_value", "id", "is_public", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "title", "to_entity_id", "to_location_id", "to_value", "updated_at") VALUES (NEW."created_at", NEW."description", NEW."display_order", NEW."entity_id", NEW."entity_type", NEW."event_date", NEW."event_date_precision", NEW."event_type", NEW."from_entity_id", NEW."from_location_id", NEW."from_value", NEW."id", NEW."is_public", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."title", NEW."to_entity_id", NEW."to_location_id", NEW."to_value", NEW."updated_at"); RETURN NULL;', hash='fafe30b7266d1d1a0a2b3486f5b7e713a8252f97', operation='UPDATE', pgid='pgtrigger_update_update_0209b', table='core_milestone', when='AFTER')),
),
migrations.AddField(
model_name='milestoneevent',
name='pgh_context',
field=models.ForeignKey(db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='pghistory.context'),
),
migrations.AddField(
model_name='milestoneevent',
name='pgh_obj',
field=models.ForeignKey(db_constraint=False, on_delete=django.db.models.deletion.DO_NOTHING, related_name='events', to='core.milestone'),
),
]

View File

@@ -1049,3 +1049,115 @@ class ApprovalTransactionMetric(models.Model):
status = "" if self.success else ""
return f"{status} Submission {self.submission_id[:8]} by {self.moderator_id[:8]}"
@pghistory.track()
class Milestone(TrackedModel):
"""
Timeline event / milestone for any entity.
Supports various event types like openings, closures, name changes,
operator changes, and other significant events. Uses a generic
entity reference pattern to work with Parks, Rides, Companies, etc.
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,
help_text="Title or name of the event",
)
description = models.TextField(
blank=True,
help_text="Detailed description of the event",
)
event_type = models.CharField(
max_length=50,
db_index=True,
help_text="Type of event (e.g., 'opening', 'closing', 'name_change', 'status_change')",
)
event_date = models.DateField(
db_index=True,
help_text="Date when the event occurred or will occur",
)
event_date_precision = models.CharField(
max_length=20,
choices=DatePrecision.choices,
default=DatePrecision.EXACT,
help_text="Precision of the event date",
)
# Generic entity reference
entity_type = models.CharField(
max_length=50,
db_index=True,
help_text="Type of entity (e.g., 'park', 'ride', 'company')",
)
entity_id = models.UUIDField(
db_index=True,
help_text="UUID of the associated entity",
)
# Display settings
is_public = models.BooleanField(
default=True,
help_text="Whether this milestone is publicly visible",
)
display_order = models.IntegerField(
default=0,
help_text="Order for displaying multiple milestones on the same date",
)
# Change tracking fields (for name_change, operator_change, etc.)
from_value = models.CharField(
max_length=200,
blank=True,
help_text="Previous value (for change events)",
)
to_value = models.CharField(
max_length=200,
blank=True,
help_text="New value (for change events)",
)
from_entity_id = models.UUIDField(
null=True,
blank=True,
help_text="Previous entity reference (e.g., old operator)",
)
to_entity_id = models.UUIDField(
null=True,
blank=True,
help_text="New entity reference (e.g., new operator)",
)
from_location_id = models.UUIDField(
null=True,
blank=True,
help_text="Previous location reference (for relocations)",
)
to_location_id = models.UUIDField(
null=True,
blank=True,
help_text="New location reference (for relocations)",
)
class Meta(TrackedModel.Meta):
ordering = ["-event_date", "display_order"]
verbose_name = "Milestone"
verbose_name_plural = "Milestones"
indexes = [
models.Index(fields=["entity_type", "entity_id"]),
models.Index(fields=["event_type", "event_date"]),
models.Index(fields=["is_public", "event_date"]),
]
def __str__(self) -> str:
return f"{self.title} ({self.event_date})"