mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-24 13:31:13 -05:00
Add ReviewEvent model and ReviewSubmissionService for review management
- Created a new ReviewEvent model to track review events with fields for content, rating, moderation status, and timestamps. - Added ForeignKey relationships to connect ReviewEvent with ContentSubmission, User, and Review. - Implemented ReviewSubmissionService to handle review submissions, including creation, updates, and moderation workflows. - Introduced atomic transactions to ensure data integrity during review submissions and updates. - Added logging for review submission and moderation actions for better traceability. - Implemented validation to prevent duplicate reviews and ensure only the review owner can update their review.
This commit is contained in:
@@ -34,39 +34,15 @@ class BaseModel(LifecycleModel, TimeStampedModel):
|
||||
|
||||
class VersionedModel(DirtyFieldsMixin, BaseModel):
|
||||
"""
|
||||
Abstract base model for entities that need version tracking.
|
||||
Abstract base model for entities that track field changes.
|
||||
|
||||
Automatically creates a version record whenever the model is created or updated.
|
||||
Uses DirtyFieldsMixin to track which fields changed.
|
||||
History tracking is now handled automatically by pghistory decorators.
|
||||
|
||||
Note: This class is kept for backwards compatibility and the DirtyFieldsMixin
|
||||
functionality, but no longer triggers custom versioning.
|
||||
"""
|
||||
|
||||
@hook(AFTER_CREATE)
|
||||
def create_version_on_create(self):
|
||||
"""Create initial version when entity is created"""
|
||||
self._create_version('created')
|
||||
|
||||
@hook(AFTER_UPDATE)
|
||||
def create_version_on_update(self):
|
||||
"""Create version when entity is updated"""
|
||||
if self.get_dirty_fields():
|
||||
self._create_version('updated')
|
||||
|
||||
def _create_version(self, change_type):
|
||||
"""
|
||||
Create a version record for this entity.
|
||||
Deferred import to avoid circular dependencies.
|
||||
"""
|
||||
try:
|
||||
from apps.versioning.services import VersionService
|
||||
VersionService.create_version(
|
||||
entity=self,
|
||||
change_type=change_type,
|
||||
changed_fields=self.get_dirty_fields() if change_type == 'updated' else {}
|
||||
)
|
||||
except ImportError:
|
||||
# Versioning app not yet available (e.g., during initial migrations)
|
||||
pass
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
|
||||
@@ -0,0 +1,936 @@
|
||||
# Generated by Django 4.2.8 on 2025-11-08 21:37
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
import model_utils.fields
|
||||
import pgtrigger.compiler
|
||||
import pgtrigger.migrations
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("core", "0001_initial"),
|
||||
("pghistory", "0006_delete_aggregateevent"),
|
||||
("entities", "0003_add_search_vector_gin_indexes"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="CompanyEvent",
|
||||
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.")),
|
||||
(
|
||||
"created",
|
||||
model_utils.fields.AutoCreatedField(
|
||||
default=django.utils.timezone.now,
|
||||
editable=False,
|
||||
verbose_name="created",
|
||||
),
|
||||
),
|
||||
(
|
||||
"modified",
|
||||
model_utils.fields.AutoLastModifiedField(
|
||||
default=django.utils.timezone.now,
|
||||
editable=False,
|
||||
verbose_name="modified",
|
||||
),
|
||||
),
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
default=uuid.uuid4, editable=False, serialize=False
|
||||
),
|
||||
),
|
||||
(
|
||||
"name",
|
||||
models.CharField(help_text="Official company name", max_length=255),
|
||||
),
|
||||
(
|
||||
"slug",
|
||||
models.SlugField(
|
||||
db_index=False,
|
||||
help_text="URL-friendly identifier",
|
||||
max_length=255,
|
||||
),
|
||||
),
|
||||
(
|
||||
"description",
|
||||
models.TextField(
|
||||
blank=True, help_text="Company description and history"
|
||||
),
|
||||
),
|
||||
(
|
||||
"company_types",
|
||||
models.JSONField(
|
||||
default=list,
|
||||
help_text="List of company types (manufacturer, operator, etc.)",
|
||||
),
|
||||
),
|
||||
(
|
||||
"founded_date",
|
||||
models.DateField(
|
||||
blank=True, help_text="Company founding date", null=True
|
||||
),
|
||||
),
|
||||
(
|
||||
"founded_date_precision",
|
||||
models.CharField(
|
||||
choices=[("year", "Year"), ("month", "Month"), ("day", "Day")],
|
||||
default="day",
|
||||
help_text="Precision of founded date",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
(
|
||||
"closed_date",
|
||||
models.DateField(
|
||||
blank=True,
|
||||
help_text="Company closure date (if applicable)",
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"closed_date_precision",
|
||||
models.CharField(
|
||||
choices=[("year", "Year"), ("month", "Month"), ("day", "Day")],
|
||||
default="day",
|
||||
help_text="Precision of closed date",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
(
|
||||
"website",
|
||||
models.URLField(blank=True, help_text="Official company website"),
|
||||
),
|
||||
(
|
||||
"logo_image_id",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
help_text="CloudFlare image ID for company logo",
|
||||
max_length=255,
|
||||
),
|
||||
),
|
||||
(
|
||||
"logo_image_url",
|
||||
models.URLField(
|
||||
blank=True, help_text="CloudFlare image URL for company logo"
|
||||
),
|
||||
),
|
||||
(
|
||||
"park_count",
|
||||
models.IntegerField(
|
||||
default=0, help_text="Number of parks operated (for operators)"
|
||||
),
|
||||
),
|
||||
(
|
||||
"ride_count",
|
||||
models.IntegerField(
|
||||
default=0,
|
||||
help_text="Number of rides manufactured (for manufacturers)",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="ParkEvent",
|
||||
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.")),
|
||||
(
|
||||
"created",
|
||||
model_utils.fields.AutoCreatedField(
|
||||
default=django.utils.timezone.now,
|
||||
editable=False,
|
||||
verbose_name="created",
|
||||
),
|
||||
),
|
||||
(
|
||||
"modified",
|
||||
model_utils.fields.AutoLastModifiedField(
|
||||
default=django.utils.timezone.now,
|
||||
editable=False,
|
||||
verbose_name="modified",
|
||||
),
|
||||
),
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
default=uuid.uuid4, editable=False, serialize=False
|
||||
),
|
||||
),
|
||||
(
|
||||
"name",
|
||||
models.CharField(help_text="Official park name", max_length=255),
|
||||
),
|
||||
(
|
||||
"slug",
|
||||
models.SlugField(
|
||||
db_index=False,
|
||||
help_text="URL-friendly identifier",
|
||||
max_length=255,
|
||||
),
|
||||
),
|
||||
(
|
||||
"description",
|
||||
models.TextField(
|
||||
blank=True, help_text="Park description and history"
|
||||
),
|
||||
),
|
||||
(
|
||||
"park_type",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("theme_park", "Theme Park"),
|
||||
("amusement_park", "Amusement Park"),
|
||||
("water_park", "Water Park"),
|
||||
(
|
||||
"family_entertainment_center",
|
||||
"Family Entertainment Center",
|
||||
),
|
||||
("traveling_park", "Traveling Park"),
|
||||
("zoo", "Zoo"),
|
||||
("aquarium", "Aquarium"),
|
||||
],
|
||||
help_text="Type of park",
|
||||
max_length=50,
|
||||
),
|
||||
),
|
||||
(
|
||||
"status",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("operating", "Operating"),
|
||||
("closed", "Closed"),
|
||||
("sbno", "Standing But Not Operating"),
|
||||
("under_construction", "Under Construction"),
|
||||
("planned", "Planned"),
|
||||
],
|
||||
default="operating",
|
||||
help_text="Current operational status",
|
||||
max_length=50,
|
||||
),
|
||||
),
|
||||
(
|
||||
"opening_date",
|
||||
models.DateField(
|
||||
blank=True, help_text="Park opening date", null=True
|
||||
),
|
||||
),
|
||||
(
|
||||
"opening_date_precision",
|
||||
models.CharField(
|
||||
choices=[("year", "Year"), ("month", "Month"), ("day", "Day")],
|
||||
default="day",
|
||||
help_text="Precision of opening date",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
(
|
||||
"closing_date",
|
||||
models.DateField(
|
||||
blank=True, help_text="Park closing date (if closed)", null=True
|
||||
),
|
||||
),
|
||||
(
|
||||
"closing_date_precision",
|
||||
models.CharField(
|
||||
choices=[("year", "Year"), ("month", "Month"), ("day", "Day")],
|
||||
default="day",
|
||||
help_text="Precision of closing date",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
(
|
||||
"latitude",
|
||||
models.DecimalField(
|
||||
blank=True,
|
||||
decimal_places=7,
|
||||
help_text="Latitude coordinate. Primary in local dev, use location_point in production.",
|
||||
max_digits=10,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"longitude",
|
||||
models.DecimalField(
|
||||
blank=True,
|
||||
decimal_places=7,
|
||||
help_text="Longitude coordinate. Primary in local dev, use location_point in production.",
|
||||
max_digits=10,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"website",
|
||||
models.URLField(blank=True, help_text="Official park website"),
|
||||
),
|
||||
(
|
||||
"banner_image_id",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
help_text="CloudFlare image ID for park banner",
|
||||
max_length=255,
|
||||
),
|
||||
),
|
||||
(
|
||||
"banner_image_url",
|
||||
models.URLField(
|
||||
blank=True, help_text="CloudFlare image URL for park banner"
|
||||
),
|
||||
),
|
||||
(
|
||||
"logo_image_id",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
help_text="CloudFlare image ID for park logo",
|
||||
max_length=255,
|
||||
),
|
||||
),
|
||||
(
|
||||
"logo_image_url",
|
||||
models.URLField(
|
||||
blank=True, help_text="CloudFlare image URL for park logo"
|
||||
),
|
||||
),
|
||||
(
|
||||
"ride_count",
|
||||
models.IntegerField(default=0, help_text="Total number of rides"),
|
||||
),
|
||||
(
|
||||
"coaster_count",
|
||||
models.IntegerField(
|
||||
default=0, help_text="Number of roller coasters"
|
||||
),
|
||||
),
|
||||
(
|
||||
"custom_fields",
|
||||
models.JSONField(
|
||||
blank=True,
|
||||
default=dict,
|
||||
help_text="Additional park-specific data",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="RideEvent",
|
||||
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.")),
|
||||
(
|
||||
"created",
|
||||
model_utils.fields.AutoCreatedField(
|
||||
default=django.utils.timezone.now,
|
||||
editable=False,
|
||||
verbose_name="created",
|
||||
),
|
||||
),
|
||||
(
|
||||
"modified",
|
||||
model_utils.fields.AutoLastModifiedField(
|
||||
default=django.utils.timezone.now,
|
||||
editable=False,
|
||||
verbose_name="modified",
|
||||
),
|
||||
),
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
default=uuid.uuid4, editable=False, serialize=False
|
||||
),
|
||||
),
|
||||
("name", models.CharField(help_text="Ride name", max_length=255)),
|
||||
(
|
||||
"slug",
|
||||
models.SlugField(
|
||||
db_index=False,
|
||||
help_text="URL-friendly identifier",
|
||||
max_length=255,
|
||||
),
|
||||
),
|
||||
(
|
||||
"description",
|
||||
models.TextField(
|
||||
blank=True, help_text="Ride description and history"
|
||||
),
|
||||
),
|
||||
(
|
||||
"ride_category",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("roller_coaster", "Roller Coaster"),
|
||||
("flat_ride", "Flat Ride"),
|
||||
("water_ride", "Water Ride"),
|
||||
("dark_ride", "Dark Ride"),
|
||||
("transport_ride", "Transport Ride"),
|
||||
("other", "Other"),
|
||||
],
|
||||
help_text="Broad ride category",
|
||||
max_length=50,
|
||||
),
|
||||
),
|
||||
(
|
||||
"ride_type",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
help_text="Specific ride type (e.g., 'Inverted Coaster', 'Drop Tower')",
|
||||
max_length=100,
|
||||
),
|
||||
),
|
||||
(
|
||||
"is_coaster",
|
||||
models.BooleanField(
|
||||
default=False, help_text="Is this ride a roller coaster?"
|
||||
),
|
||||
),
|
||||
(
|
||||
"status",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("operating", "Operating"),
|
||||
("closed", "Closed"),
|
||||
("sbno", "Standing But Not Operating"),
|
||||
("relocated", "Relocated"),
|
||||
("under_construction", "Under Construction"),
|
||||
("planned", "Planned"),
|
||||
],
|
||||
default="operating",
|
||||
help_text="Current operational status",
|
||||
max_length=50,
|
||||
),
|
||||
),
|
||||
(
|
||||
"opening_date",
|
||||
models.DateField(
|
||||
blank=True, help_text="Ride opening date", null=True
|
||||
),
|
||||
),
|
||||
(
|
||||
"opening_date_precision",
|
||||
models.CharField(
|
||||
choices=[("year", "Year"), ("month", "Month"), ("day", "Day")],
|
||||
default="day",
|
||||
help_text="Precision of opening date",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
(
|
||||
"closing_date",
|
||||
models.DateField(
|
||||
blank=True, help_text="Ride closing date (if closed)", null=True
|
||||
),
|
||||
),
|
||||
(
|
||||
"closing_date_precision",
|
||||
models.CharField(
|
||||
choices=[("year", "Year"), ("month", "Month"), ("day", "Day")],
|
||||
default="day",
|
||||
help_text="Precision of closing date",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
(
|
||||
"height",
|
||||
models.DecimalField(
|
||||
blank=True,
|
||||
decimal_places=1,
|
||||
help_text="Height in feet",
|
||||
max_digits=6,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"speed",
|
||||
models.DecimalField(
|
||||
blank=True,
|
||||
decimal_places=1,
|
||||
help_text="Top speed in mph",
|
||||
max_digits=6,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"length",
|
||||
models.DecimalField(
|
||||
blank=True,
|
||||
decimal_places=1,
|
||||
help_text="Track/ride length in feet",
|
||||
max_digits=8,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"duration",
|
||||
models.IntegerField(
|
||||
blank=True, help_text="Ride duration in seconds", null=True
|
||||
),
|
||||
),
|
||||
(
|
||||
"inversions",
|
||||
models.IntegerField(
|
||||
blank=True,
|
||||
help_text="Number of inversions (for coasters)",
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"capacity",
|
||||
models.IntegerField(
|
||||
blank=True,
|
||||
help_text="Hourly capacity (riders per hour)",
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"image_id",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
help_text="CloudFlare image ID for main photo",
|
||||
max_length=255,
|
||||
),
|
||||
),
|
||||
(
|
||||
"image_url",
|
||||
models.URLField(
|
||||
blank=True, help_text="CloudFlare image URL for main photo"
|
||||
),
|
||||
),
|
||||
(
|
||||
"custom_fields",
|
||||
models.JSONField(
|
||||
blank=True,
|
||||
default=dict,
|
||||
help_text="Additional ride-specific data",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="RideModelEvent",
|
||||
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.")),
|
||||
(
|
||||
"created",
|
||||
model_utils.fields.AutoCreatedField(
|
||||
default=django.utils.timezone.now,
|
||||
editable=False,
|
||||
verbose_name="created",
|
||||
),
|
||||
),
|
||||
(
|
||||
"modified",
|
||||
model_utils.fields.AutoLastModifiedField(
|
||||
default=django.utils.timezone.now,
|
||||
editable=False,
|
||||
verbose_name="modified",
|
||||
),
|
||||
),
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
default=uuid.uuid4, editable=False, serialize=False
|
||||
),
|
||||
),
|
||||
(
|
||||
"name",
|
||||
models.CharField(
|
||||
help_text="Model name (e.g., 'Inverted Coaster', 'Boomerang')",
|
||||
max_length=255,
|
||||
),
|
||||
),
|
||||
(
|
||||
"slug",
|
||||
models.SlugField(
|
||||
db_index=False,
|
||||
help_text="URL-friendly identifier",
|
||||
max_length=255,
|
||||
),
|
||||
),
|
||||
(
|
||||
"description",
|
||||
models.TextField(
|
||||
blank=True, help_text="Model description and technical details"
|
||||
),
|
||||
),
|
||||
(
|
||||
"model_type",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("coaster_model", "Roller Coaster Model"),
|
||||
("flat_ride_model", "Flat Ride Model"),
|
||||
("water_ride_model", "Water Ride Model"),
|
||||
("dark_ride_model", "Dark Ride Model"),
|
||||
("transport_ride_model", "Transport Ride Model"),
|
||||
],
|
||||
help_text="Type of ride model",
|
||||
max_length=50,
|
||||
),
|
||||
),
|
||||
(
|
||||
"typical_height",
|
||||
models.DecimalField(
|
||||
blank=True,
|
||||
decimal_places=1,
|
||||
help_text="Typical height in feet",
|
||||
max_digits=6,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"typical_speed",
|
||||
models.DecimalField(
|
||||
blank=True,
|
||||
decimal_places=1,
|
||||
help_text="Typical speed in mph",
|
||||
max_digits=6,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"typical_capacity",
|
||||
models.IntegerField(
|
||||
blank=True, help_text="Typical hourly capacity", null=True
|
||||
),
|
||||
),
|
||||
(
|
||||
"image_id",
|
||||
models.CharField(
|
||||
blank=True, help_text="CloudFlare image ID", max_length=255
|
||||
),
|
||||
),
|
||||
(
|
||||
"image_url",
|
||||
models.URLField(blank=True, help_text="CloudFlare image URL"),
|
||||
),
|
||||
(
|
||||
"installation_count",
|
||||
models.IntegerField(
|
||||
default=0, help_text="Number of installations worldwide"
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="company",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="insert_insert",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
func='INSERT INTO "entities_companyevent" ("closed_date", "closed_date_precision", "company_types", "created", "description", "founded_date", "founded_date_precision", "id", "location_id", "logo_image_id", "logo_image_url", "modified", "name", "park_count", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "ride_count", "slug", "website") VALUES (NEW."closed_date", NEW."closed_date_precision", NEW."company_types", NEW."created", NEW."description", NEW."founded_date", NEW."founded_date_precision", NEW."id", NEW."location_id", NEW."logo_image_id", NEW."logo_image_url", NEW."modified", NEW."name", NEW."park_count", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."ride_count", NEW."slug", NEW."website"); RETURN NULL;',
|
||||
hash="891243f1479adc9ae67c894ec6824b89b7997086",
|
||||
operation="INSERT",
|
||||
pgid="pgtrigger_insert_insert_ed498",
|
||||
table="entities_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 "entities_companyevent" ("closed_date", "closed_date_precision", "company_types", "created", "description", "founded_date", "founded_date_precision", "id", "location_id", "logo_image_id", "logo_image_url", "modified", "name", "park_count", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "ride_count", "slug", "website") VALUES (NEW."closed_date", NEW."closed_date_precision", NEW."company_types", NEW."created", NEW."description", NEW."founded_date", NEW."founded_date_precision", NEW."id", NEW."location_id", NEW."logo_image_id", NEW."logo_image_url", NEW."modified", NEW."name", NEW."park_count", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."ride_count", NEW."slug", NEW."website"); RETURN NULL;',
|
||||
hash="5d0f3d8dbb199afd7474de393b075b8e72c481fd",
|
||||
operation="UPDATE",
|
||||
pgid="pgtrigger_update_update_2d89e",
|
||||
table="entities_company",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="park",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="insert_insert",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
func='INSERT INTO "entities_parkevent" ("banner_image_id", "banner_image_url", "closing_date", "closing_date_precision", "coaster_count", "created", "custom_fields", "description", "id", "latitude", "location_id", "logo_image_id", "logo_image_url", "longitude", "modified", "name", "opening_date", "opening_date_precision", "operator_id", "park_type", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "ride_count", "slug", "status", "website") VALUES (NEW."banner_image_id", NEW."banner_image_url", NEW."closing_date", NEW."closing_date_precision", NEW."coaster_count", NEW."created", NEW."custom_fields", NEW."description", NEW."id", NEW."latitude", NEW."location_id", NEW."logo_image_id", NEW."logo_image_url", NEW."longitude", NEW."modified", NEW."name", NEW."opening_date", NEW."opening_date_precision", NEW."operator_id", NEW."park_type", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."ride_count", NEW."slug", NEW."status", NEW."website"); RETURN NULL;',
|
||||
hash="e03ce2a0516ff75f1703a6ccf069ce931f3123bc",
|
||||
operation="INSERT",
|
||||
pgid="pgtrigger_insert_insert_a5515",
|
||||
table="entities_park",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="park",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="update_update",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
|
||||
func='INSERT INTO "entities_parkevent" ("banner_image_id", "banner_image_url", "closing_date", "closing_date_precision", "coaster_count", "created", "custom_fields", "description", "id", "latitude", "location_id", "logo_image_id", "logo_image_url", "longitude", "modified", "name", "opening_date", "opening_date_precision", "operator_id", "park_type", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "ride_count", "slug", "status", "website") VALUES (NEW."banner_image_id", NEW."banner_image_url", NEW."closing_date", NEW."closing_date_precision", NEW."coaster_count", NEW."created", NEW."custom_fields", NEW."description", NEW."id", NEW."latitude", NEW."location_id", NEW."logo_image_id", NEW."logo_image_url", NEW."longitude", NEW."modified", NEW."name", NEW."opening_date", NEW."opening_date_precision", NEW."operator_id", NEW."park_type", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."ride_count", NEW."slug", NEW."status", NEW."website"); RETURN NULL;',
|
||||
hash="0e01b4eac8ef56aeb039c870c7ac194d2615012e",
|
||||
operation="UPDATE",
|
||||
pgid="pgtrigger_update_update_b436a",
|
||||
table="entities_park",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="ride",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="insert_insert",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
func='INSERT INTO "entities_rideevent" ("capacity", "closing_date", "closing_date_precision", "created", "custom_fields", "description", "duration", "height", "id", "image_id", "image_url", "inversions", "is_coaster", "length", "manufacturer_id", "model_id", "modified", "name", "opening_date", "opening_date_precision", "park_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "ride_category", "ride_type", "slug", "speed", "status") VALUES (NEW."capacity", NEW."closing_date", NEW."closing_date_precision", NEW."created", NEW."custom_fields", NEW."description", NEW."duration", NEW."height", NEW."id", NEW."image_id", NEW."image_url", NEW."inversions", NEW."is_coaster", NEW."length", NEW."manufacturer_id", NEW."model_id", NEW."modified", NEW."name", NEW."opening_date", NEW."opening_date_precision", NEW."park_id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."ride_category", NEW."ride_type", NEW."slug", NEW."speed", NEW."status"); RETURN NULL;',
|
||||
hash="02f95397d881bd95627424df1a144956d5f15f8d",
|
||||
operation="INSERT",
|
||||
pgid="pgtrigger_insert_insert_23173",
|
||||
table="entities_ride",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="ride",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="update_update",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
|
||||
func='INSERT INTO "entities_rideevent" ("capacity", "closing_date", "closing_date_precision", "created", "custom_fields", "description", "duration", "height", "id", "image_id", "image_url", "inversions", "is_coaster", "length", "manufacturer_id", "model_id", "modified", "name", "opening_date", "opening_date_precision", "park_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "ride_category", "ride_type", "slug", "speed", "status") VALUES (NEW."capacity", NEW."closing_date", NEW."closing_date_precision", NEW."created", NEW."custom_fields", NEW."description", NEW."duration", NEW."height", NEW."id", NEW."image_id", NEW."image_url", NEW."inversions", NEW."is_coaster", NEW."length", NEW."manufacturer_id", NEW."model_id", NEW."modified", NEW."name", NEW."opening_date", NEW."opening_date_precision", NEW."park_id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."ride_category", NEW."ride_type", NEW."slug", NEW."speed", NEW."status"); RETURN NULL;',
|
||||
hash="9377ca0c44ec8e548254d371a95e9ff7a6eb8684",
|
||||
operation="UPDATE",
|
||||
pgid="pgtrigger_update_update_c2972",
|
||||
table="entities_ride",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="ridemodel",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="insert_insert",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
func='INSERT INTO "entities_ridemodelevent" ("created", "description", "id", "image_id", "image_url", "installation_count", "manufacturer_id", "model_type", "modified", "name", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "slug", "typical_capacity", "typical_height", "typical_speed") VALUES (NEW."created", NEW."description", NEW."id", NEW."image_id", NEW."image_url", NEW."installation_count", NEW."manufacturer_id", NEW."model_type", NEW."modified", NEW."name", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."slug", NEW."typical_capacity", NEW."typical_height", NEW."typical_speed"); RETURN NULL;',
|
||||
hash="580a9d8a429d5140bc6bf553d6e0f9c06b7a7dec",
|
||||
operation="INSERT",
|
||||
pgid="pgtrigger_insert_insert_04de6",
|
||||
table="entities_ridemodel",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="ridemodel",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="update_update",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
|
||||
func='INSERT INTO "entities_ridemodelevent" ("created", "description", "id", "image_id", "image_url", "installation_count", "manufacturer_id", "model_type", "modified", "name", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "slug", "typical_capacity", "typical_height", "typical_speed") VALUES (NEW."created", NEW."description", NEW."id", NEW."image_id", NEW."image_url", NEW."installation_count", NEW."manufacturer_id", NEW."model_type", NEW."modified", NEW."name", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."slug", NEW."typical_capacity", NEW."typical_height", NEW."typical_speed"); RETURN NULL;',
|
||||
hash="b7d6519a2c97e7b543494b67c4f25826439a02ef",
|
||||
operation="UPDATE",
|
||||
pgid="pgtrigger_update_update_a70fd",
|
||||
table="entities_ridemodel",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="ridemodelevent",
|
||||
name="manufacturer",
|
||||
field=models.ForeignKey(
|
||||
db_constraint=False,
|
||||
help_text="Manufacturer of this ride model",
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
related_query_name="+",
|
||||
to="entities.company",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="ridemodelevent",
|
||||
name="pgh_context",
|
||||
field=models.ForeignKey(
|
||||
db_constraint=False,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
related_query_name="+",
|
||||
to="pghistory.context",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="ridemodelevent",
|
||||
name="pgh_obj",
|
||||
field=models.ForeignKey(
|
||||
db_constraint=False,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="events",
|
||||
related_query_name="+",
|
||||
to="entities.ridemodel",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="rideevent",
|
||||
name="manufacturer",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
db_constraint=False,
|
||||
help_text="Ride manufacturer",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
related_query_name="+",
|
||||
to="entities.company",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="rideevent",
|
||||
name="model",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
db_constraint=False,
|
||||
help_text="Specific ride model",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
related_query_name="+",
|
||||
to="entities.ridemodel",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="rideevent",
|
||||
name="park",
|
||||
field=models.ForeignKey(
|
||||
db_constraint=False,
|
||||
help_text="Park where ride is located",
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
related_query_name="+",
|
||||
to="entities.park",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="rideevent",
|
||||
name="pgh_context",
|
||||
field=models.ForeignKey(
|
||||
db_constraint=False,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
related_query_name="+",
|
||||
to="pghistory.context",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="rideevent",
|
||||
name="pgh_obj",
|
||||
field=models.ForeignKey(
|
||||
db_constraint=False,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="events",
|
||||
related_query_name="+",
|
||||
to="entities.ride",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="parkevent",
|
||||
name="location",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
db_constraint=False,
|
||||
help_text="Park location",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
related_query_name="+",
|
||||
to="core.locality",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="parkevent",
|
||||
name="operator",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
db_constraint=False,
|
||||
help_text="Current park operator",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
related_query_name="+",
|
||||
to="entities.company",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="parkevent",
|
||||
name="pgh_context",
|
||||
field=models.ForeignKey(
|
||||
db_constraint=False,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
related_query_name="+",
|
||||
to="pghistory.context",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="parkevent",
|
||||
name="pgh_obj",
|
||||
field=models.ForeignKey(
|
||||
db_constraint=False,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="events",
|
||||
related_query_name="+",
|
||||
to="entities.park",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="companyevent",
|
||||
name="location",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
db_constraint=False,
|
||||
help_text="Company headquarters location",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
related_query_name="+",
|
||||
to="core.locality",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="companyevent",
|
||||
name="pgh_context",
|
||||
field=models.ForeignKey(
|
||||
db_constraint=False,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
related_query_name="+",
|
||||
to="pghistory.context",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="companyevent",
|
||||
name="pgh_obj",
|
||||
field=models.ForeignKey(
|
||||
db_constraint=False,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="events",
|
||||
related_query_name="+",
|
||||
to="entities.company",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -12,6 +12,7 @@ from django.conf import settings
|
||||
from django.contrib.contenttypes.fields import GenericRelation
|
||||
from django.utils.text import slugify
|
||||
from django_lifecycle import hook, AFTER_CREATE, AFTER_UPDATE, BEFORE_SAVE
|
||||
import pghistory
|
||||
|
||||
from apps.core.models import VersionedModel, BaseModel
|
||||
|
||||
@@ -27,6 +28,7 @@ if _using_postgis:
|
||||
from django.contrib.postgres.search import SearchVectorField
|
||||
|
||||
|
||||
@pghistory.track()
|
||||
class Company(VersionedModel):
|
||||
"""
|
||||
Represents a company in the amusement industry.
|
||||
@@ -194,6 +196,7 @@ class Company(VersionedModel):
|
||||
return photos
|
||||
|
||||
|
||||
@pghistory.track()
|
||||
class RideModel(VersionedModel):
|
||||
"""
|
||||
Represents a specific ride model from a manufacturer.
|
||||
@@ -328,6 +331,7 @@ class RideModel(VersionedModel):
|
||||
return photos
|
||||
|
||||
|
||||
@pghistory.track()
|
||||
class Park(VersionedModel):
|
||||
"""
|
||||
Represents an amusement park, theme park, water park, or FEC.
|
||||
@@ -638,6 +642,7 @@ if _using_postgis:
|
||||
)
|
||||
|
||||
|
||||
@pghistory.track()
|
||||
class Ride(VersionedModel):
|
||||
"""
|
||||
Represents an individual ride or roller coaster.
|
||||
|
||||
@@ -152,7 +152,6 @@ def generate_entity_report(entity_type, entity_id):
|
||||
from apps.entities.models import Park, Ride, Company, RideModel
|
||||
from apps.media.models import Photo
|
||||
from apps.moderation.models import ContentSubmission
|
||||
from apps.versioning.models import EntityVersion
|
||||
|
||||
try:
|
||||
model_map = {
|
||||
@@ -206,10 +205,8 @@ def generate_entity_report(entity_type, entity_id):
|
||||
status='pending'
|
||||
).count(),
|
||||
},
|
||||
'versions': EntityVersion.objects.filter(
|
||||
content_type__model=entity_type.lower(),
|
||||
object_id=entity_id
|
||||
).count(),
|
||||
# Version history now tracked via pghistory Event models
|
||||
# Can query {ModelName}Event if needed (e.g., ParkEvent, RideEvent)
|
||||
}
|
||||
|
||||
logger.info(f"Generated report for {entity_type} {entity_id}")
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
# Generated by Django 4.2.8 on 2025-11-08 21:32
|
||||
|
||||
from django.conf import settings
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
import model_utils.fields
|
||||
import pgtrigger.compiler
|
||||
import pgtrigger.migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("moderation", "0001_initial"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
("pghistory", "0006_delete_aggregateevent"),
|
||||
("contenttypes", "0002_remove_content_type_name"),
|
||||
("reviews", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="ReviewEvent",
|
||||
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",
|
||||
model_utils.fields.AutoCreatedField(
|
||||
default=django.utils.timezone.now,
|
||||
editable=False,
|
||||
verbose_name="created",
|
||||
),
|
||||
),
|
||||
(
|
||||
"modified",
|
||||
model_utils.fields.AutoLastModifiedField(
|
||||
default=django.utils.timezone.now,
|
||||
editable=False,
|
||||
verbose_name="modified",
|
||||
),
|
||||
),
|
||||
("object_id", models.PositiveIntegerField()),
|
||||
("title", models.CharField(max_length=200)),
|
||||
("content", models.TextField()),
|
||||
(
|
||||
"rating",
|
||||
models.IntegerField(
|
||||
help_text="Rating from 1 to 5 stars",
|
||||
validators=[
|
||||
django.core.validators.MinValueValidator(1),
|
||||
django.core.validators.MaxValueValidator(5),
|
||||
],
|
||||
),
|
||||
),
|
||||
(
|
||||
"visit_date",
|
||||
models.DateField(
|
||||
blank=True, help_text="Date the user visited", null=True
|
||||
),
|
||||
),
|
||||
(
|
||||
"wait_time_minutes",
|
||||
models.PositiveIntegerField(
|
||||
blank=True, help_text="Wait time in minutes", null=True
|
||||
),
|
||||
),
|
||||
(
|
||||
"helpful_votes",
|
||||
models.PositiveIntegerField(
|
||||
default=0,
|
||||
help_text="Number of users who found this review helpful",
|
||||
),
|
||||
),
|
||||
(
|
||||
"total_votes",
|
||||
models.PositiveIntegerField(
|
||||
default=0,
|
||||
help_text="Total number of votes (helpful + not helpful)",
|
||||
),
|
||||
),
|
||||
(
|
||||
"moderation_status",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("pending", "Pending"),
|
||||
("approved", "Approved"),
|
||||
("rejected", "Rejected"),
|
||||
],
|
||||
default="pending",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
(
|
||||
"moderation_notes",
|
||||
models.TextField(blank=True, help_text="Notes from moderator"),
|
||||
),
|
||||
("moderated_at", models.DateTimeField(blank=True, null=True)),
|
||||
],
|
||||
options={
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="review",
|
||||
name="submission",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
help_text="ContentSubmission that created this review",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="reviews",
|
||||
to="moderation.contentsubmission",
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="review",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="insert_insert",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
func='INSERT INTO "reviews_reviewevent" ("content", "content_type_id", "created", "helpful_votes", "id", "moderated_at", "moderated_by_id", "moderation_notes", "moderation_status", "modified", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "rating", "submission_id", "title", "total_votes", "user_id", "visit_date", "wait_time_minutes") VALUES (NEW."content", NEW."content_type_id", NEW."created", NEW."helpful_votes", NEW."id", NEW."moderated_at", NEW."moderated_by_id", NEW."moderation_notes", NEW."moderation_status", NEW."modified", NEW."object_id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."rating", NEW."submission_id", NEW."title", NEW."total_votes", NEW."user_id", NEW."visit_date", NEW."wait_time_minutes"); RETURN NULL;',
|
||||
hash="b35102b3c04881bef39a259f1105a6032033b6d7",
|
||||
operation="INSERT",
|
||||
pgid="pgtrigger_insert_insert_7a7c1",
|
||||
table="reviews_review",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="review",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="update_update",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
|
||||
func='INSERT INTO "reviews_reviewevent" ("content", "content_type_id", "created", "helpful_votes", "id", "moderated_at", "moderated_by_id", "moderation_notes", "moderation_status", "modified", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "rating", "submission_id", "title", "total_votes", "user_id", "visit_date", "wait_time_minutes") VALUES (NEW."content", NEW."content_type_id", NEW."created", NEW."helpful_votes", NEW."id", NEW."moderated_at", NEW."moderated_by_id", NEW."moderation_notes", NEW."moderation_status", NEW."modified", NEW."object_id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."rating", NEW."submission_id", NEW."title", NEW."total_votes", NEW."user_id", NEW."visit_date", NEW."wait_time_minutes"); RETURN NULL;',
|
||||
hash="252cddc558c9724c0ef840a91c1d0ebd03a1b7a2",
|
||||
operation="UPDATE",
|
||||
pgid="pgtrigger_update_update_b34c8",
|
||||
table="reviews_review",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="reviewevent",
|
||||
name="content_type",
|
||||
field=models.ForeignKey(
|
||||
db_constraint=False,
|
||||
limit_choices_to={"model__in": ("park", "ride")},
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
related_query_name="+",
|
||||
to="contenttypes.contenttype",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="reviewevent",
|
||||
name="moderated_by",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
db_constraint=False,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
related_query_name="+",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="reviewevent",
|
||||
name="pgh_context",
|
||||
field=models.ForeignKey(
|
||||
db_constraint=False,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
related_query_name="+",
|
||||
to="pghistory.context",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="reviewevent",
|
||||
name="pgh_obj",
|
||||
field=models.ForeignKey(
|
||||
db_constraint=False,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="events",
|
||||
related_query_name="+",
|
||||
to="reviews.review",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="reviewevent",
|
||||
name="submission",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
db_constraint=False,
|
||||
help_text="ContentSubmission that created this review",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
related_query_name="+",
|
||||
to="moderation.contentsubmission",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="reviewevent",
|
||||
name="user",
|
||||
field=models.ForeignKey(
|
||||
db_constraint=False,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
related_query_name="+",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -4,8 +4,10 @@ from django.conf import settings
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from model_utils.models import TimeStampedModel
|
||||
import pghistory
|
||||
|
||||
|
||||
@pghistory.track()
|
||||
class Review(TimeStampedModel):
|
||||
"""
|
||||
User reviews for parks or rides.
|
||||
@@ -90,6 +92,16 @@ class Review(TimeStampedModel):
|
||||
related_name='moderated_reviews'
|
||||
)
|
||||
|
||||
# Link to ContentSubmission (Sacred Pipeline integration)
|
||||
submission = models.ForeignKey(
|
||||
'moderation.ContentSubmission',
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='reviews',
|
||||
help_text="ContentSubmission that created this review"
|
||||
)
|
||||
|
||||
# Photos related to this review (via media.Photo model with generic relation)
|
||||
photos = GenericRelation('media.Photo')
|
||||
|
||||
@@ -124,24 +136,6 @@ class Review(TimeStampedModel):
|
||||
def is_pending(self):
|
||||
"""Check if review is pending moderation."""
|
||||
return self.moderation_status == self.MODERATION_PENDING
|
||||
|
||||
def approve(self, moderator, notes=''):
|
||||
"""Approve the review."""
|
||||
from django.utils import timezone
|
||||
self.moderation_status = self.MODERATION_APPROVED
|
||||
self.moderated_by = moderator
|
||||
self.moderated_at = timezone.now()
|
||||
self.moderation_notes = notes
|
||||
self.save()
|
||||
|
||||
def reject(self, moderator, notes=''):
|
||||
"""Reject the review."""
|
||||
from django.utils import timezone
|
||||
self.moderation_status = self.MODERATION_REJECTED
|
||||
self.moderated_by = moderator
|
||||
self.moderated_at = timezone.now()
|
||||
self.moderation_notes = notes
|
||||
self.save()
|
||||
|
||||
|
||||
class ReviewHelpfulVote(TimeStampedModel):
|
||||
|
||||
378
django/apps/reviews/services.py
Normal file
378
django/apps/reviews/services.py
Normal file
@@ -0,0 +1,378 @@
|
||||
"""
|
||||
Review services for ThrillWiki.
|
||||
|
||||
This module provides business logic for review submissions through the Sacred Pipeline.
|
||||
All reviews must flow through ModerationService to ensure consistency with the rest of the system.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from django.db import transaction
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
from apps.moderation.services import ModerationService
|
||||
from apps.reviews.models import Review
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ReviewSubmissionService:
|
||||
"""
|
||||
Service class for creating and managing review submissions.
|
||||
|
||||
All reviews flow through the ContentSubmission pipeline, ensuring:
|
||||
- Consistent moderation workflow
|
||||
- FSM state machine transitions
|
||||
- 15-minute lock mechanism
|
||||
- Atomic transaction handling
|
||||
- Automatic versioning via pghistory
|
||||
- Audit trail via ContentSubmission
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
@transaction.atomic
|
||||
def create_review_submission(
|
||||
user,
|
||||
entity,
|
||||
rating,
|
||||
title,
|
||||
content,
|
||||
visit_date=None,
|
||||
wait_time_minutes=None,
|
||||
**kwargs
|
||||
):
|
||||
"""
|
||||
Create a review submission through the Sacred Pipeline.
|
||||
|
||||
This method creates a ContentSubmission with SubmissionItems for each review field.
|
||||
If the user is a moderator, the submission is auto-approved and the Review is created immediately.
|
||||
Otherwise, the submission enters the pending moderation queue.
|
||||
|
||||
Args:
|
||||
user: User creating the review
|
||||
entity: Entity being reviewed (Park or Ride)
|
||||
rating: Rating from 1-5 stars
|
||||
title: Review title
|
||||
content: Review content text
|
||||
visit_date: Optional date of visit
|
||||
wait_time_minutes: Optional wait time in minutes
|
||||
**kwargs: Additional metadata (source, ip_address, user_agent)
|
||||
|
||||
Returns:
|
||||
tuple: (ContentSubmission, Review or None)
|
||||
Review will be None if pending moderation
|
||||
|
||||
Raises:
|
||||
ValidationError: If validation fails
|
||||
"""
|
||||
# Check if user is moderator (for bypass)
|
||||
is_moderator = hasattr(user, 'role') and user.role.is_moderator if user else False
|
||||
|
||||
# Get entity ContentType
|
||||
entity_type = ContentType.objects.get_for_model(entity)
|
||||
|
||||
# Check for duplicate review
|
||||
existing = Review.objects.filter(
|
||||
user=user,
|
||||
content_type=entity_type,
|
||||
object_id=entity.id
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
raise ValidationError(
|
||||
f"User has already reviewed this {entity_type.model}. "
|
||||
f"Use update method to modify existing review."
|
||||
)
|
||||
|
||||
# Build submission items for each review field
|
||||
items_data = [
|
||||
{
|
||||
'field_name': 'rating',
|
||||
'field_label': 'Rating',
|
||||
'old_value': None,
|
||||
'new_value': str(rating),
|
||||
'change_type': 'add',
|
||||
'is_required': True,
|
||||
'order': 0
|
||||
},
|
||||
{
|
||||
'field_name': 'title',
|
||||
'field_label': 'Title',
|
||||
'old_value': None,
|
||||
'new_value': title,
|
||||
'change_type': 'add',
|
||||
'is_required': True,
|
||||
'order': 1
|
||||
},
|
||||
{
|
||||
'field_name': 'content',
|
||||
'field_label': 'Review Content',
|
||||
'old_value': None,
|
||||
'new_value': content,
|
||||
'change_type': 'add',
|
||||
'is_required': True,
|
||||
'order': 2
|
||||
},
|
||||
]
|
||||
|
||||
# Add optional fields if provided
|
||||
if visit_date is not None:
|
||||
items_data.append({
|
||||
'field_name': 'visit_date',
|
||||
'field_label': 'Visit Date',
|
||||
'old_value': None,
|
||||
'new_value': str(visit_date),
|
||||
'change_type': 'add',
|
||||
'is_required': False,
|
||||
'order': 3
|
||||
})
|
||||
|
||||
if wait_time_minutes is not None:
|
||||
items_data.append({
|
||||
'field_name': 'wait_time_minutes',
|
||||
'field_label': 'Wait Time (minutes)',
|
||||
'old_value': None,
|
||||
'new_value': str(wait_time_minutes),
|
||||
'change_type': 'add',
|
||||
'is_required': False,
|
||||
'order': 4
|
||||
})
|
||||
|
||||
# Create submission through ModerationService
|
||||
submission = ModerationService.create_submission(
|
||||
user=user,
|
||||
entity=entity,
|
||||
submission_type='review',
|
||||
title=f"Review: {title[:50]}",
|
||||
description=f"User review for {entity_type.model}: {entity}",
|
||||
items_data=items_data,
|
||||
metadata={
|
||||
'rating': rating,
|
||||
'entity_type': entity_type.model,
|
||||
},
|
||||
auto_submit=True,
|
||||
source=kwargs.get('source', 'api'),
|
||||
ip_address=kwargs.get('ip_address'),
|
||||
user_agent=kwargs.get('user_agent', '')
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Review submission created: {submission.id} by {user.email} "
|
||||
f"for {entity_type.model} {entity.id}"
|
||||
)
|
||||
|
||||
# MODERATOR BYPASS: Auto-approve if user is moderator
|
||||
review = None
|
||||
if is_moderator:
|
||||
logger.info(f"Moderator bypass: Auto-approving submission {submission.id}")
|
||||
|
||||
# Approve through ModerationService (this triggers atomic transaction)
|
||||
submission = ModerationService.approve_submission(submission.id, user)
|
||||
|
||||
# Create the Review record
|
||||
review = ReviewSubmissionService._create_review_from_submission(
|
||||
submission=submission,
|
||||
entity=entity,
|
||||
user=user
|
||||
)
|
||||
|
||||
logger.info(f"Review auto-created for moderator: {review.id}")
|
||||
|
||||
return submission, review
|
||||
|
||||
@staticmethod
|
||||
@transaction.atomic
|
||||
def _create_review_from_submission(submission, entity, user):
|
||||
"""
|
||||
Create a Review record from an approved ContentSubmission.
|
||||
|
||||
This is called internally when a submission is approved.
|
||||
Extracts data from SubmissionItems and creates the Review.
|
||||
|
||||
Args:
|
||||
submission: Approved ContentSubmission
|
||||
entity: Entity being reviewed
|
||||
user: User who created the review
|
||||
|
||||
Returns:
|
||||
Review: Created review instance
|
||||
"""
|
||||
# Extract data from submission items
|
||||
items = submission.items.all()
|
||||
review_data = {}
|
||||
|
||||
for item in items:
|
||||
if item.field_name == 'rating':
|
||||
review_data['rating'] = int(item.new_value)
|
||||
elif item.field_name == 'title':
|
||||
review_data['title'] = item.new_value
|
||||
elif item.field_name == 'content':
|
||||
review_data['content'] = item.new_value
|
||||
elif item.field_name == 'visit_date':
|
||||
from datetime import datetime
|
||||
review_data['visit_date'] = datetime.fromisoformat(item.new_value).date()
|
||||
elif item.field_name == 'wait_time_minutes':
|
||||
review_data['wait_time_minutes'] = int(item.new_value)
|
||||
|
||||
# Get entity ContentType
|
||||
entity_type = ContentType.objects.get_for_model(entity)
|
||||
|
||||
# Create Review
|
||||
review = Review.objects.create(
|
||||
user=user,
|
||||
content_type=entity_type,
|
||||
object_id=entity.id,
|
||||
submission=submission,
|
||||
moderation_status=Review.MODERATION_APPROVED,
|
||||
moderated_by=submission.reviewed_by,
|
||||
moderated_at=submission.reviewed_at,
|
||||
**review_data
|
||||
)
|
||||
|
||||
# pghistory will automatically track this creation
|
||||
|
||||
logger.info(
|
||||
f"Review created from submission: {review.id} "
|
||||
f"(submission: {submission.id})"
|
||||
)
|
||||
|
||||
return review
|
||||
|
||||
@staticmethod
|
||||
@transaction.atomic
|
||||
def update_review_submission(review, user, **update_data):
|
||||
"""
|
||||
Update an existing review by creating a new submission.
|
||||
|
||||
This follows the Sacred Pipeline by creating a new ContentSubmission
|
||||
for the update, which must be approved before changes take effect.
|
||||
|
||||
Args:
|
||||
review: Existing Review to update
|
||||
user: User making the update (must be review owner)
|
||||
**update_data: Fields to update (rating, title, content, etc.)
|
||||
|
||||
Returns:
|
||||
ContentSubmission: The update submission
|
||||
|
||||
Raises:
|
||||
ValidationError: If user is not the review owner
|
||||
"""
|
||||
# Verify ownership
|
||||
if review.user != user:
|
||||
raise ValidationError("Only the review owner can update their review")
|
||||
|
||||
# Check if user is moderator (for bypass)
|
||||
is_moderator = hasattr(user, 'role') and user.role.is_moderator if user else False
|
||||
|
||||
# Get entity
|
||||
entity = review.content_object
|
||||
if not entity:
|
||||
raise ValidationError("Reviewed entity no longer exists")
|
||||
|
||||
# Build submission items for changed fields
|
||||
items_data = []
|
||||
order = 0
|
||||
|
||||
for field_name, new_value in update_data.items():
|
||||
if field_name in ['rating', 'title', 'content', 'visit_date', 'wait_time_minutes']:
|
||||
old_value = getattr(review, field_name)
|
||||
|
||||
# Only include if value actually changed
|
||||
if old_value != new_value:
|
||||
items_data.append({
|
||||
'field_name': field_name,
|
||||
'field_label': field_name.replace('_', ' ').title(),
|
||||
'old_value': str(old_value) if old_value else None,
|
||||
'new_value': str(new_value),
|
||||
'change_type': 'modify',
|
||||
'is_required': field_name in ['rating', 'title', 'content'],
|
||||
'order': order
|
||||
})
|
||||
order += 1
|
||||
|
||||
if not items_data:
|
||||
raise ValidationError("No changes detected")
|
||||
|
||||
# Create update submission
|
||||
submission = ModerationService.create_submission(
|
||||
user=user,
|
||||
entity=entity,
|
||||
submission_type='update',
|
||||
title=f"Review Update: {review.title[:50]}",
|
||||
description=f"User updating review #{review.id}",
|
||||
items_data=items_data,
|
||||
metadata={
|
||||
'review_id': str(review.id),
|
||||
'update_type': 'review',
|
||||
},
|
||||
auto_submit=True,
|
||||
source='api'
|
||||
)
|
||||
|
||||
logger.info(f"Review update submission created: {submission.id}")
|
||||
|
||||
# MODERATOR BYPASS: Auto-approve if moderator
|
||||
if is_moderator:
|
||||
submission = ModerationService.approve_submission(submission.id, user)
|
||||
|
||||
# Apply updates to review
|
||||
for item in submission.items.filter(status='approved'):
|
||||
setattr(review, item.field_name, item.new_value)
|
||||
|
||||
review.moderation_status = Review.MODERATION_APPROVED
|
||||
review.moderated_by = user
|
||||
review.save()
|
||||
|
||||
logger.info(f"Review update auto-approved for moderator: {review.id}")
|
||||
else:
|
||||
# Regular user: mark review as pending
|
||||
review.moderation_status = Review.MODERATION_PENDING
|
||||
review.save()
|
||||
|
||||
return submission
|
||||
|
||||
@staticmethod
|
||||
def apply_review_approval(submission):
|
||||
"""
|
||||
Apply an approved review submission.
|
||||
|
||||
This is called by ModerationService when a review submission is approved.
|
||||
For new reviews, creates the Review record.
|
||||
For updates, applies changes to existing Review.
|
||||
|
||||
Args:
|
||||
submission: Approved ContentSubmission
|
||||
|
||||
Returns:
|
||||
Review: The created or updated review
|
||||
"""
|
||||
entity = submission.entity
|
||||
user = submission.user
|
||||
|
||||
if submission.submission_type == 'review':
|
||||
# New review
|
||||
return ReviewSubmissionService._create_review_from_submission(
|
||||
submission, entity, user
|
||||
)
|
||||
elif submission.submission_type == 'update':
|
||||
# Update existing review
|
||||
review_id = submission.metadata.get('review_id')
|
||||
if not review_id:
|
||||
raise ValidationError("Missing review_id in submission metadata")
|
||||
|
||||
review = Review.objects.get(id=review_id)
|
||||
|
||||
# Apply approved changes
|
||||
for item in submission.items.filter(status='approved'):
|
||||
setattr(review, item.field_name, item.new_value)
|
||||
|
||||
review.moderation_status = Review.MODERATION_APPROVED
|
||||
review.moderated_by = submission.reviewed_by
|
||||
review.moderated_at = submission.reviewed_at
|
||||
review.save()
|
||||
|
||||
logger.info(f"Review updated from submission: {review.id}")
|
||||
return review
|
||||
else:
|
||||
raise ValidationError(f"Invalid submission type: {submission.submission_type}")
|
||||
Reference in New Issue
Block a user