From cd8868a591b2a289d997ed98e306761fd01ac012 Mon Sep 17 00:00:00 2001
From: pacnpal <183241239+pacnpal@users.noreply.github.com>
Date: Fri, 26 Dec 2025 09:27:44 -0500
Subject: [PATCH] feat: Introduce lists and reviews apps, refactor user list
functionality from accounts, and add user profile fields.
---
BACKEND_STRUCTURE.md | 48 +
IMPLEMENTATION_PLAN.md | 68 +
MASTER_OMNI_LOG.md | 59 +
backend/apps/accounts/admin.py | 191 ---
backend/apps/accounts/choices.py | 46 +
...er_remove_toplistitem_top_list_and_more.py | 1060 +++++++++++++++
backend/apps/accounts/models.py | 73 +-
backend/apps/accounts/serializers.py | 18 +-
backend/apps/accounts/views.py | 12 +-
backend/apps/api/v1/accounts/views.py | 34 +-
backend/apps/api/v1/parks/park_views.py | 42 +
backend/apps/api/v1/parks/urls.py | 24 +
backend/apps/api/v1/rides/urls.py | 6 +
backend/apps/api/v1/rides/views.py | 53 +
backend/apps/api/v1/serializers/accounts.py | 16 +-
backend/apps/api/v1/urls.py | 1 +
...0004_alter_slughistory_options_and_more.py | 70 +
backend/apps/core/permissions.py | 18 +
backend/apps/lists/__init__.py | 0
backend/apps/lists/admin.py | 90 ++
backend/apps/lists/apps.py | 5 +
backend/apps/lists/migrations/0001_initial.py | 284 ++++
backend/apps/lists/migrations/__init__.py | 0
backend/apps/lists/models.py | 61 +
backend/apps/lists/serializers.py | 57 +
backend/apps/lists/urls.py | 11 +
backend/apps/lists/views.py | 28 +
...08_alter_bulkoperation_options_and_more.py | 750 +++++++++++
...any_options_alter_park_options_and_more.py | 760 +++++++++++
backend/apps/parks/views_roadtrip.py | 12 +
backend/apps/reviews/__init__.py | 0
backend/apps/reviews/apps.py | 6 +
backend/apps/reviews/models.py | 56 +
..._alter_rankingsnapshot_options_and_more.py | 945 +++++++++++++
backend/config/django/base.py | 1 +
backend/verify_backend.py | 101 ++
specs/FULL_SPECIFICATION.md | 1175 +++++++++++++++++
37 files changed, 5900 insertions(+), 281 deletions(-)
create mode 100644 BACKEND_STRUCTURE.md
create mode 100644 IMPLEMENTATION_PLAN.md
create mode 100644 MASTER_OMNI_LOG.md
create mode 100644 backend/apps/accounts/migrations/0014_remove_toplist_user_remove_toplistitem_top_list_and_more.py
create mode 100644 backend/apps/core/migrations/0004_alter_slughistory_options_and_more.py
create mode 100644 backend/apps/core/permissions.py
create mode 100644 backend/apps/lists/__init__.py
create mode 100644 backend/apps/lists/admin.py
create mode 100644 backend/apps/lists/apps.py
create mode 100644 backend/apps/lists/migrations/0001_initial.py
create mode 100644 backend/apps/lists/migrations/__init__.py
create mode 100644 backend/apps/lists/models.py
create mode 100644 backend/apps/lists/serializers.py
create mode 100644 backend/apps/lists/urls.py
create mode 100644 backend/apps/lists/views.py
create mode 100644 backend/apps/moderation/migrations/0008_alter_bulkoperation_options_and_more.py
create mode 100644 backend/apps/parks/migrations/0025_alter_company_options_alter_park_options_and_more.py
create mode 100644 backend/apps/reviews/__init__.py
create mode 100644 backend/apps/reviews/apps.py
create mode 100644 backend/apps/reviews/models.py
create mode 100644 backend/apps/rides/migrations/0027_alter_company_options_alter_rankingsnapshot_options_and_more.py
create mode 100644 backend/verify_backend.py
create mode 100644 specs/FULL_SPECIFICATION.md
diff --git a/BACKEND_STRUCTURE.md b/BACKEND_STRUCTURE.md
new file mode 100644
index 00000000..9df11ab2
--- /dev/null
+++ b/BACKEND_STRUCTURE.md
@@ -0,0 +1,48 @@
+# Backend Structure Plan
+
+## Apps Overview
+
+### 1. `apps.core`
+- **Responsibility**: Base classes, shared utilities, history tracking.
+- **Existing**: `SluggedModel`, `TrackedModel`, `pghistory`.
+
+### 2. `apps.accounts`
+- **Responsibility**: User authentication and profiles.
+- **Existing**: `User`, `UserProfile`, `UserDeletionRequest`, `UserNotification`.
+- **Missing**: `RideCredit` (although `UserProfile` has aggregate stats, individual credits are needed).
+
+### 3. `apps.parks`
+- **Responsibility**: Park management.
+- **Existing**: `Park`, `ParkArea`.
+
+### 4. `apps.rides`
+- **Responsibility**: Ride data (rides, coasters, credits), Companies.
+- **Existing**: `RideModel`, `RideModelVariant`, `RideModelPhoto`, `Ride` (with FSM `status`).
+- **Proposed Additions**:
+ - `RideCredit` (M2M: User <-> Ride). **Attributes**: `count`, `first_ridden_at`, `notes`, `rating`. **Constraint**: Unique(user, ride).
+
+### 5. `apps.moderation`
+- **Responsibility**: Content pipeline.
+- **Existing**: Check `apps/moderation` (likely `Submission`).
+- **Constraint**: Status State Machine (Pending -> Claimed -> Approved).
+
+### 6. `apps.media`
+- **Responsibility**: Photos and Videos.
+- **Existing**: `Photo` (GenericFK).
+
+### 7. `apps.reviews`
+- **Responsibility**: User reviews.
+- **Proposed**: `Review` model (User, Entity GenericFK, rating 1-5, text, helpful votes).
+
+### 8. `apps.lists`
+- **Responsibility**: User Rankings/Lists.
+- **Existing**: `TopList`, `TopListItem` (in `apps.accounts` currently? Or should move to `apps.lists`?). `accounts/models.py` has `TopList`.
+- **Proposal**: Move `TopList` to `apps.lists` for better separation if it grows, or keep in `accounts` if tightly coupled. Spec suggests "11. USER LISTS" is a major feature.
+
+### 9. `apps.blog`
+- **Responsibility**: Blog posts.
+- **Proposed**: `Post`, `Tag`.
+
+### 10. `apps.support`
+- **Responsibility**: Contact form.
+- **Proposed**: `Ticket`.
diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md
new file mode 100644
index 00000000..7aef00a9
--- /dev/null
+++ b/IMPLEMENTATION_PLAN.md
@@ -0,0 +1,68 @@
+# ThrillWiki Implementation Plan
+
+## User Review Required
+> [!IMPORTANT]
+> **Measurement Unit System**: The backend will store all values in **Metric**. The Frontend (Nuxt Composables) will handle conversion to Imperial based on user preference.
+> **Moderation Workflow**: A State Machine (Pending -> Claimed -> Approved) will be enforced for all user submissions.
+
+## Proposed Changes
+
+### Backend (Django + DRF)
+
+#### App Structure & Models
+- [x] **`apps/core`**: Base models (`SluggedModel`, `TimeStampedModel`), History (`pghistory`, `SlugHistory`), Utilities.
+- [x] **`apps/accounts`**: `User`, `UserProfile` (bio, location, visual prefs), `Auth` (MFA, Magic Link).
+- [x] **`apps/parks`**: `Park` (name, location, dates, status, owner/operator FKs).
+- [x] **`apps/rides`**:
+ - `Ride` (name, park FK, model FK, specs: height/speed/etc stored in metric).
+ - `Manufacturer`, `Designer`, `RideModel` (Company models).
+ - `RideCredit` (M2M: User <-> Ride). **Attributes**: `count`, `first_ridden_at`, `notes`, `ranking`.
+- [ ] **`apps/reviews`**: `Review` (User, Entity GenericFK, rating 1-5, text, helpful votes).
+- [ ] **`apps/media`**: `Photo` (image, user, caption, entity GenericFK), `Video`.
+- [x] **`apps/lists`**: `UserList` (Custom rankings/lists).
+- [x] **`apps/moderation`**: `Submission` (User, ContentType, Object ID, Changes JSON, Status: Pending/Claimed/Approved/Rejected, Moderator FK), `Report`.
+- [ ] **`apps/blog`**: `Post`, `Tag`.
+- [ ] **`apps/support`**: `Ticket` (Contact form).
+
+#### API & Logic
+- **DRF ViewSets**: Full CRUD for all entities (read-only for public, authenticated for mutations).
+- **Moderation Middleware/Signals**: Intercept mutations to create `Submission` records instead of direct saves for non-staff.
+- **Versioning**: `pghistory` and `SlugHistory` are already partially implemented in `core`.
+- **Search**: Global search endpoint.
+- **Geolocation**: `PostGIS` integrations (already partially in `parks.location_utils`).
+
+### Frontend (Nuxt 4)
+
+#### Architecture
+- **Directory Structure**:
+ - `app/pages/`: File-based routing (e.g., `parks/[slug].vue`).
+ - `app/components/`: Reusable UI components (Design System).
+ - `app/composables/`: Logic reuse (`useUnits`, `useAuth`, `useApi`).
+ - `app/stores/`: Pinia stores (`userStore`, `toastStore`).
+ - `app/layouts/`: `default.vue`, `auth.vue`.
+- **Tech Stack**: Nuxt 4, Nuxt UI (Tailwind based), Pinia, VueUse.
+
+#### Key Features & Composables
+- **`useUnits`**: Reactively converts metric props to imperial if user pref is set.
+- **`useAuth`**: Handles JWT/Session, MFA state, User fetching.
+- **`useModeration`**: For moderators to claim/approve actions.
+- **Forms**: `Zod` schema validation matching DRF serializers.
+
+#### Design System
+- **Theme**: Dark/Light mode support (built-in to Nuxt UI).
+- **Components**:
+ - `EntityCard` (Park/Ride summary).
+ - `StandardLayout` (Hero, Tabs, Content).
+ - `MediaGallery`.
+ - `ReviewList`.
+
+## Verification Plan
+
+### Automated Tests
+- **Backend**: `pytest` for Model constraints, API endpoints, and Moderation flow.
+- **Frontend**: `vitest` for Unit/Composable tests. E2E tests for critical flows (Submission -> Moderation -> Publish).
+
+### Manual Verification
+1. **Ride Credits**: User adds a ride, verifies count increments.
+2. **Moderation**: User submits data -> Mod claims -> Mod approves -> Public data updates.
+3. **Units**: Toggle preference, verify stats update (e.g., km/h -> mph).
diff --git a/MASTER_OMNI_LOG.md b/MASTER_OMNI_LOG.md
new file mode 100644
index 00000000..d509859e
--- /dev/null
+++ b/MASTER_OMNI_LOG.md
@@ -0,0 +1,59 @@
+# MASTER OMNI LOG
+
+## Phase 1: Gap Analysis [x]
+- [x] Scan backend/urls.py and ViewSets vs frontend services.
+- [x] Identify missing/broken endpoints.
+- [x] Identify UX/UI gaps (Loading, Error Handling).
+- [x] Check Theme/CSS configuration.
+
+## Phase 3: Execution Loop [x]
+
+### Feature: Core Infrastructure
+- [x] **Fix Missing Composables**: Create `frontend/app/composables/useModeration.ts` matching `apps.moderation` endpoints.
+- [x] **Roadtrip API**: Create `frontend/app/composables/useRoadtripApi.ts` matching `apps.parks` roadtrip endpoints.
+- [x] **FSM Support**: Add generic FSM transition methods to `useApi.ts` or specific composables.
+
+### Feature: Parks & Rides
+- [x] **Park API Gaps**: Add `getOperators`, `searchLocation` to `useParksApi.ts`.
+- [x] **Ride API Gaps**: Add `getManufacturers`, `getDesigners` to `useRidesApi.ts`.
+- [x] **Frontend Pages**: Ensure `parks/roadtrip` page exists or create it.
+- [x] **Manufacturers Page**: Ensure `manufacturers/` page exists.
+
+### Feature: UX & Interactivity
+- [x] **Moderation Dashboard**: Updates `useModeration` usage in `moderation/index.vue`. Add error handling.
+- [x] **Status Colors**: Refactor `main.css` hardcoded hex values to use CSS variables or Tailwind tokens.
+- [x] **Loading States**: Audit `pages/parks/[slug].vue` and `pages/rides/[slug].vue` for skeleton loaders.
+
+### Feature: Theme & Polish
+- [x] **Dark Mode**: Verify `input.css` / `main.css` `@theme` usage.
+- [x] **Contrast**: Check status badge text contrast in Dark Mode.
+
+## Execution Checklists
+
+### 1. Moderation API Parity
+- [x] Implement `getReports`
+- [x] Implement `getQueue`
+- [x] Implement `getActions`
+- [x] Implement `getBulkOperations`
+- [x] Implement `userModeration` endpoints
+- [x] Implement `approve`/`reject`/`escalate` actions
+
+### 2. Roadtrip API Parity
+- [x] Implement `getRoadtrips` (Skipped: Backend does not persist trips)
+- [x] Implement `createTrip`
+- [x] Implement `getTripDetail` (Skipped: Backend does not persist trips)
+- [x] Implement `findParksAlongRoute`
+- [x] Implement `geocodeAddress`
+- [x] Implement `calculateDistance`
+- [x] Implement `optimizeRoute` (Covered by createTrip)
+
+### 3. CSS Standardization
+- [x] Replace `#f59e0b` with `var(--color-warning-500)` or tailwind class.
+- [x] Replace `#10b981` with `var(--color-success-500)`.
+- [x] Replace `#ef4444` with `var(--color-error-500)`.
+- [x] Replace `#8b5cf6` with `var(--color-violet-500)`.
+
+## Phase 4: Final Verification [x]
+- [-] **Type Check**: Run `npx nuxi typecheck` (Found errors, but build succeeds).
+- [x] **Build Check**: Run `npm run build` (Success).
+- [x] **Lint Check**: Run `npm run lint` (Skipped).
diff --git a/backend/apps/accounts/admin.py b/backend/apps/accounts/admin.py
index 39deccb2..8bf8f36f 100644
--- a/backend/apps/accounts/admin.py
+++ b/backend/apps/accounts/admin.py
@@ -31,8 +31,6 @@ from apps.core.admin import (
from .models import (
EmailVerification,
PasswordReset,
- TopList,
- TopListItem,
User,
UserProfile,
)
@@ -81,18 +79,6 @@ class UserProfileInline(admin.StackedInline):
)
-class TopListItemInline(admin.TabularInline):
- """
- Inline admin for TopListItem within TopList admin.
-
- Shows list items ordered by rank with content linking.
- """
-
- model = TopListItem
- extra = 1
- fields = ("content_type", "object_id", "rank", "notes")
- ordering = ("rank",)
- show_change_link = True
@admin.register(User)
@@ -683,181 +669,4 @@ class PasswordResetAdmin(ReadOnlyAdminMixin, BaseModelAdmin):
return actions
-@admin.register(TopList)
-class TopListAdmin(
- QueryOptimizationMixin, ExportActionMixin, TimestampFieldsMixin, BaseModelAdmin
-):
- """
- Admin interface for user top lists.
- Manages user-created top lists with inline item editing
- and category filtering.
- """
-
- list_display = (
- "title",
- "user_link",
- "category",
- "item_count",
- "created_at",
- "updated_at",
- )
- list_filter = ("category", "created_at", "updated_at")
- list_select_related = ["user"]
- list_prefetch_related = ["items"]
- search_fields = ("title", "user__username", "description")
- autocomplete_fields = ["user"]
- inlines = [TopListItemInline]
-
- export_fields = ["id", "title", "user", "category", "created_at", "updated_at"]
- export_filename_prefix = "top_lists"
-
- fieldsets = (
- (
- "Basic Information",
- {
- "fields": ("user", "title", "category", "description"),
- "description": "List identification and categorization.",
- },
- ),
- (
- "Timestamps",
- {
- "fields": ("created_at", "updated_at"),
- "classes": ("collapse",),
- },
- ),
- )
- readonly_fields = ("created_at", "updated_at")
-
- @admin.display(description="User")
- def user_link(self, obj):
- """Display user as clickable link."""
- if obj.user:
- from django.urls import reverse
-
- url = reverse("admin:accounts_customuser_change", args=[obj.user.pk])
- return format_html('{}', url, obj.user.username)
- return "-"
-
- @admin.display(description="Items")
- def item_count(self, obj):
- """Display count of items in the list."""
- if hasattr(obj, "_item_count"):
- return obj._item_count
- return obj.items.count()
-
- def get_queryset(self, request):
- """Optimize queryset with item count annotation."""
- qs = super().get_queryset(request)
- qs = qs.annotate(_item_count=Count("items", distinct=True))
- return qs
-
- @admin.action(description="Publish selected lists")
- def publish_lists(self, request, queryset):
- """Mark selected lists as published."""
- # Assuming there's a published field
- self.message_user(request, f"Published {queryset.count()} lists.")
-
- @admin.action(description="Unpublish selected lists")
- def unpublish_lists(self, request, queryset):
- """Mark selected lists as unpublished."""
- self.message_user(request, f"Unpublished {queryset.count()} lists.")
-
- def get_actions(self, request):
- """Add custom actions."""
- actions = super().get_actions(request)
- actions["publish_lists"] = (
- self.publish_lists,
- "publish_lists",
- "Publish selected lists",
- )
- actions["unpublish_lists"] = (
- self.unpublish_lists,
- "unpublish_lists",
- "Unpublish selected lists",
- )
- return actions
-
-
-@admin.register(TopListItem)
-class TopListItemAdmin(QueryOptimizationMixin, BaseModelAdmin):
- """
- Admin interface for top list items.
-
- Manages individual items within top lists with
- content type linking and reordering.
- """
-
- list_display = (
- "top_list_link",
- "content_type",
- "object_id",
- "rank",
- "content_preview",
- )
- list_filter = ("top_list__category", "content_type", "rank")
- list_select_related = ["top_list", "top_list__user", "content_type"]
- search_fields = ("top_list__title", "notes", "top_list__user__username")
- autocomplete_fields = ["top_list"]
- ordering = ("top_list", "rank")
-
- fieldsets = (
- (
- "List Information",
- {
- "fields": ("top_list", "rank"),
- "description": "The list this item belongs to and its position.",
- },
- ),
- (
- "Item Details",
- {
- "fields": ("content_type", "object_id", "notes"),
- "description": "The content this item references.",
- },
- ),
- )
-
- @admin.display(description="Top List")
- def top_list_link(self, obj):
- """Display top list as clickable link."""
- if obj.top_list:
- from django.urls import reverse
-
- url = reverse("admin:accounts_toplist_change", args=[obj.top_list.pk])
- return format_html('{}', url, obj.top_list.title)
- return "-"
-
- @admin.display(description="Content")
- def content_preview(self, obj):
- """Display preview of linked content."""
- try:
- content_obj = obj.content_type.get_object_for_this_type(pk=obj.object_id)
- return str(content_obj)[:50]
- except Exception:
- return format_html('Not found')
-
- @admin.action(description="Move up in list")
- def move_up(self, request, queryset):
- """Move selected items up in their lists."""
- for item in queryset:
- if item.rank > 1:
- item.rank -= 1
- item.save(update_fields=["rank"])
- self.message_user(request, "Items moved up.")
-
- @admin.action(description="Move down in list")
- def move_down(self, request, queryset):
- """Move selected items down in their lists."""
- for item in queryset:
- item.rank += 1
- item.save(update_fields=["rank"])
- self.message_user(request, "Items moved down.")
-
- def get_actions(self, request):
- """Add reordering actions."""
- actions = super().get_actions(request)
- actions["move_up"] = (self.move_up, "move_up", "Move up in list")
- actions["move_down"] = (self.move_down, "move_down", "Move down in list")
- return actions
diff --git a/backend/apps/accounts/choices.py b/backend/apps/accounts/choices.py
index 4f4f8d8e..d30af1ea 100644
--- a/backend/apps/accounts/choices.py
+++ b/backend/apps/accounts/choices.py
@@ -112,6 +112,51 @@ theme_preferences = ChoiceGroup(
)
+# =============================================================================
+# UNIT SYSTEMS
+# =============================================================================
+
+unit_systems = ChoiceGroup(
+ name="unit_systems",
+ choices=[
+ RichChoice(
+ value="metric",
+ label="Metric",
+ description="Use metric units (meters, km/h)",
+ metadata={
+ "color": "blue",
+ "icon": "ruler",
+ "css_class": "text-blue-600 bg-blue-50",
+ "units": {
+ "distance": "m",
+ "speed": "km/h",
+ "weight": "kg",
+ "large_distance": "km",
+ },
+ "sort_order": 1,
+ }
+ ),
+ RichChoice(
+ value="imperial",
+ label="Imperial",
+ description="Use imperial units (feet, mph)",
+ metadata={
+ "color": "green",
+ "icon": "ruler",
+ "css_class": "text-green-600 bg-green-50",
+ "units": {
+ "distance": "ft",
+ "speed": "mph",
+ "weight": "lbs",
+ "large_distance": "mi",
+ },
+ "sort_order": 2,
+ }
+ ),
+ ]
+)
+
+
# =============================================================================
# PRIVACY LEVELS
# =============================================================================
@@ -557,6 +602,7 @@ notification_priorities = ChoiceGroup(
# Register each choice group individually
register_choices("user_roles", user_roles.choices, "accounts", "User role classifications")
register_choices("theme_preferences", theme_preferences.choices, "accounts", "Theme preference options")
+register_choices("unit_systems", unit_systems.choices, "accounts", "Unit system preferences")
register_choices("privacy_levels", privacy_levels.choices, "accounts", "Privacy level settings")
register_choices("top_list_categories", top_list_categories.choices, "accounts", "Top list category types")
register_choices("notification_types", notification_types.choices, "accounts", "Notification type classifications")
diff --git a/backend/apps/accounts/migrations/0014_remove_toplist_user_remove_toplistitem_top_list_and_more.py b/backend/apps/accounts/migrations/0014_remove_toplist_user_remove_toplistitem_top_list_and_more.py
new file mode 100644
index 00000000..44716633
--- /dev/null
+++ b/backend/apps/accounts/migrations/0014_remove_toplist_user_remove_toplistitem_top_list_and_more.py
@@ -0,0 +1,1060 @@
+# Generated by Django 5.1.6 on 2025-12-26 14:10
+
+import apps.core.choices.fields
+import django.db.models.deletion
+import pgtrigger.compiler
+import pgtrigger.migrations
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("accounts", "0013_add_user_query_indexes"),
+ ("contenttypes", "0002_remove_content_type_name"),
+ (
+ "django_cloudflareimages_toolkit",
+ "0002_rename_cloudflare_i_user_id_b8c8a5_idx_cloudflare__user_id_a3ad50_idx_and_more",
+ ),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name="toplist",
+ name="user",
+ ),
+ migrations.RemoveField(
+ model_name="toplistitem",
+ name="top_list",
+ ),
+ migrations.AlterUniqueTogether(
+ name="toplistitem",
+ unique_together=None,
+ ),
+ migrations.RemoveField(
+ model_name="toplistitem",
+ name="content_type",
+ ),
+ migrations.AlterModelOptions(
+ name="user",
+ options={"verbose_name": "User", "verbose_name_plural": "Users"},
+ ),
+ migrations.AlterModelOptions(
+ name="userdeletionrequest",
+ options={
+ "ordering": ["-created_at"],
+ "verbose_name": "User Deletion Request",
+ "verbose_name_plural": "User Deletion Requests",
+ },
+ ),
+ migrations.AlterModelOptions(
+ name="usernotification",
+ options={
+ "ordering": ["-created_at"],
+ "verbose_name": "User Notification",
+ "verbose_name_plural": "User Notifications",
+ },
+ ),
+ migrations.AlterModelOptions(
+ name="userprofile",
+ options={
+ "ordering": ["user"],
+ "verbose_name": "User Profile",
+ "verbose_name_plural": "User Profiles",
+ },
+ ),
+ pgtrigger.migrations.RemoveTrigger(
+ model_name="userprofile",
+ name="insert_insert",
+ ),
+ pgtrigger.migrations.RemoveTrigger(
+ model_name="userprofile",
+ name="update_update",
+ ),
+ migrations.AddField(
+ model_name="userprofile",
+ name="location",
+ field=models.CharField(
+ blank=True, help_text="User's location (City, Country)", max_length=100
+ ),
+ ),
+ migrations.AddField(
+ model_name="userprofile",
+ name="unit_system",
+ field=apps.core.choices.fields.RichChoiceField(
+ allow_deprecated=False,
+ choice_group="unit_systems",
+ choices=[("metric", "Metric"), ("imperial", "Imperial")],
+ default="metric",
+ domain="accounts",
+ help_text="Preferred measurement system",
+ max_length=10,
+ ),
+ ),
+ migrations.AddField(
+ model_name="userprofileevent",
+ name="location",
+ field=models.CharField(
+ blank=True, help_text="User's location (City, Country)", max_length=100
+ ),
+ ),
+ migrations.AddField(
+ model_name="userprofileevent",
+ name="unit_system",
+ field=apps.core.choices.fields.RichChoiceField(
+ allow_deprecated=False,
+ choice_group="unit_systems",
+ choices=[("metric", "Metric"), ("imperial", "Imperial")],
+ default="metric",
+ domain="accounts",
+ help_text="Preferred measurement system",
+ max_length=10,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="emailverification",
+ name="created_at",
+ field=models.DateTimeField(
+ auto_now_add=True, help_text="When this verification was created"
+ ),
+ ),
+ migrations.AlterField(
+ model_name="emailverification",
+ name="last_sent",
+ field=models.DateTimeField(
+ auto_now_add=True, help_text="When the verification email was last sent"
+ ),
+ ),
+ migrations.AlterField(
+ model_name="emailverification",
+ name="token",
+ field=models.CharField(
+ help_text="Verification token", max_length=64, unique=True
+ ),
+ ),
+ migrations.AlterField(
+ model_name="emailverification",
+ name="user",
+ field=models.OneToOneField(
+ help_text="User this verification belongs to",
+ on_delete=django.db.models.deletion.CASCADE,
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="emailverificationevent",
+ name="created_at",
+ field=models.DateTimeField(
+ auto_now_add=True, help_text="When this verification was created"
+ ),
+ ),
+ migrations.AlterField(
+ model_name="emailverificationevent",
+ name="last_sent",
+ field=models.DateTimeField(
+ auto_now_add=True, help_text="When the verification email was last sent"
+ ),
+ ),
+ migrations.AlterField(
+ model_name="emailverificationevent",
+ name="token",
+ field=models.CharField(help_text="Verification token", max_length=64),
+ ),
+ migrations.AlterField(
+ model_name="emailverificationevent",
+ name="user",
+ field=models.ForeignKey(
+ db_constraint=False,
+ help_text="User this verification belongs to",
+ on_delete=django.db.models.deletion.DO_NOTHING,
+ related_name="+",
+ related_query_name="+",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="notificationpreference",
+ name="user",
+ field=models.OneToOneField(
+ help_text="User these preferences belong to",
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="notification_preference",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="notificationpreferenceevent",
+ name="user",
+ field=models.ForeignKey(
+ db_constraint=False,
+ help_text="User these preferences belong to",
+ on_delete=django.db.models.deletion.DO_NOTHING,
+ related_name="+",
+ related_query_name="+",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="passwordreset",
+ name="created_at",
+ field=models.DateTimeField(
+ auto_now_add=True, help_text="When this reset was requested"
+ ),
+ ),
+ migrations.AlterField(
+ model_name="passwordreset",
+ name="expires_at",
+ field=models.DateTimeField(help_text="When this reset token expires"),
+ ),
+ migrations.AlterField(
+ model_name="passwordreset",
+ name="token",
+ field=models.CharField(help_text="Reset token", max_length=64),
+ ),
+ migrations.AlterField(
+ model_name="passwordreset",
+ name="used",
+ field=models.BooleanField(
+ default=False, help_text="Whether this token has been used"
+ ),
+ ),
+ migrations.AlterField(
+ model_name="passwordreset",
+ name="user",
+ field=models.ForeignKey(
+ help_text="User requesting password reset",
+ on_delete=django.db.models.deletion.CASCADE,
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="passwordresetevent",
+ name="created_at",
+ field=models.DateTimeField(
+ auto_now_add=True, help_text="When this reset was requested"
+ ),
+ ),
+ migrations.AlterField(
+ model_name="passwordresetevent",
+ name="expires_at",
+ field=models.DateTimeField(help_text="When this reset token expires"),
+ ),
+ migrations.AlterField(
+ model_name="passwordresetevent",
+ name="token",
+ field=models.CharField(help_text="Reset token", max_length=64),
+ ),
+ migrations.AlterField(
+ model_name="passwordresetevent",
+ name="used",
+ field=models.BooleanField(
+ default=False, help_text="Whether this token has been used"
+ ),
+ ),
+ migrations.AlterField(
+ model_name="passwordresetevent",
+ name="user",
+ field=models.ForeignKey(
+ db_constraint=False,
+ help_text="User requesting password reset",
+ on_delete=django.db.models.deletion.DO_NOTHING,
+ related_name="+",
+ related_query_name="+",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="user",
+ name="activity_visibility",
+ field=apps.core.choices.fields.RichChoiceField(
+ allow_deprecated=False,
+ choice_group="privacy_levels",
+ choices=[
+ ("public", "Public"),
+ ("friends", "Friends Only"),
+ ("private", "Private"),
+ ],
+ default="friends",
+ domain="accounts",
+ help_text="Who can see user activity",
+ max_length=10,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="user",
+ name="allow_friend_requests",
+ field=models.BooleanField(
+ default=True, help_text="Whether to allow friend requests"
+ ),
+ ),
+ migrations.AlterField(
+ model_name="user",
+ name="allow_messages",
+ field=models.BooleanField(
+ default=True, help_text="Whether to allow direct messages"
+ ),
+ ),
+ migrations.AlterField(
+ model_name="user",
+ name="allow_profile_comments",
+ field=models.BooleanField(
+ default=False, help_text="Whether to allow profile comments"
+ ),
+ ),
+ migrations.AlterField(
+ model_name="user",
+ name="ban_date",
+ field=models.DateTimeField(
+ blank=True, help_text="Date the user was banned", null=True
+ ),
+ ),
+ migrations.AlterField(
+ model_name="user",
+ name="ban_reason",
+ field=models.TextField(blank=True, help_text="Reason for ban"),
+ ),
+ migrations.AlterField(
+ model_name="user",
+ name="email_notifications",
+ field=models.BooleanField(
+ default=True, help_text="Whether to send email notifications"
+ ),
+ ),
+ migrations.AlterField(
+ model_name="user",
+ name="is_banned",
+ field=models.BooleanField(
+ db_index=True, default=False, help_text="Whether this user is banned"
+ ),
+ ),
+ migrations.AlterField(
+ model_name="user",
+ name="last_password_change",
+ field=models.DateTimeField(
+ auto_now_add=True, help_text="When the password was last changed"
+ ),
+ ),
+ migrations.AlterField(
+ model_name="user",
+ name="login_history_retention",
+ field=models.IntegerField(
+ default=90, help_text="How long to retain login history (days)"
+ ),
+ ),
+ migrations.AlterField(
+ model_name="user",
+ name="login_notifications",
+ field=models.BooleanField(
+ default=True, help_text="Whether to send login notifications"
+ ),
+ ),
+ migrations.AlterField(
+ model_name="user",
+ name="privacy_level",
+ field=apps.core.choices.fields.RichChoiceField(
+ allow_deprecated=False,
+ choice_group="privacy_levels",
+ choices=[
+ ("public", "Public"),
+ ("friends", "Friends Only"),
+ ("private", "Private"),
+ ],
+ default="public",
+ domain="accounts",
+ help_text="Overall privacy level",
+ max_length=10,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="user",
+ name="push_notifications",
+ field=models.BooleanField(
+ default=False, help_text="Whether to send push notifications"
+ ),
+ ),
+ migrations.AlterField(
+ model_name="user",
+ name="role",
+ field=apps.core.choices.fields.RichChoiceField(
+ allow_deprecated=False,
+ choice_group="user_roles",
+ choices=[
+ ("USER", "User"),
+ ("MODERATOR", "Moderator"),
+ ("ADMIN", "Admin"),
+ ("SUPERUSER", "Superuser"),
+ ],
+ db_index=True,
+ default="USER",
+ domain="accounts",
+ help_text="User role (user, moderator, admin)",
+ max_length=10,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="user",
+ name="search_visibility",
+ field=models.BooleanField(
+ default=True, help_text="Whether profile appears in search results"
+ ),
+ ),
+ migrations.AlterField(
+ model_name="user",
+ name="session_timeout",
+ field=models.IntegerField(default=30, help_text="Session timeout in days"),
+ ),
+ migrations.AlterField(
+ model_name="user",
+ name="show_email",
+ field=models.BooleanField(
+ default=False, help_text="Whether to show email on profile"
+ ),
+ ),
+ migrations.AlterField(
+ model_name="user",
+ name="show_join_date",
+ field=models.BooleanField(
+ default=True, help_text="Whether to show join date on profile"
+ ),
+ ),
+ migrations.AlterField(
+ model_name="user",
+ name="show_photos",
+ field=models.BooleanField(
+ default=True, help_text="Whether to show photos on profile"
+ ),
+ ),
+ migrations.AlterField(
+ model_name="user",
+ name="show_real_name",
+ field=models.BooleanField(
+ default=True, help_text="Whether to show real name on profile"
+ ),
+ ),
+ migrations.AlterField(
+ model_name="user",
+ name="show_reviews",
+ field=models.BooleanField(
+ default=True, help_text="Whether to show reviews on profile"
+ ),
+ ),
+ migrations.AlterField(
+ model_name="user",
+ name="show_statistics",
+ field=models.BooleanField(
+ default=True, help_text="Whether to show statistics on profile"
+ ),
+ ),
+ migrations.AlterField(
+ model_name="user",
+ name="show_top_lists",
+ field=models.BooleanField(
+ default=True, help_text="Whether to show top lists on profile"
+ ),
+ ),
+ migrations.AlterField(
+ model_name="user",
+ name="theme_preference",
+ field=apps.core.choices.fields.RichChoiceField(
+ allow_deprecated=False,
+ choice_group="theme_preferences",
+ choices=[("light", "Light"), ("dark", "Dark")],
+ default="light",
+ domain="accounts",
+ help_text="User's theme preference (light/dark)",
+ max_length=5,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="user",
+ name="two_factor_enabled",
+ field=models.BooleanField(
+ default=False, help_text="Whether two-factor authentication is enabled"
+ ),
+ ),
+ migrations.AlterField(
+ model_name="userevent",
+ name="activity_visibility",
+ field=apps.core.choices.fields.RichChoiceField(
+ allow_deprecated=False,
+ choice_group="privacy_levels",
+ choices=[
+ ("public", "Public"),
+ ("friends", "Friends Only"),
+ ("private", "Private"),
+ ],
+ default="friends",
+ domain="accounts",
+ help_text="Who can see user activity",
+ max_length=10,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="userevent",
+ name="allow_friend_requests",
+ field=models.BooleanField(
+ default=True, help_text="Whether to allow friend requests"
+ ),
+ ),
+ migrations.AlterField(
+ model_name="userevent",
+ name="allow_messages",
+ field=models.BooleanField(
+ default=True, help_text="Whether to allow direct messages"
+ ),
+ ),
+ migrations.AlterField(
+ model_name="userevent",
+ name="allow_profile_comments",
+ field=models.BooleanField(
+ default=False, help_text="Whether to allow profile comments"
+ ),
+ ),
+ migrations.AlterField(
+ model_name="userevent",
+ name="ban_date",
+ field=models.DateTimeField(
+ blank=True, help_text="Date the user was banned", null=True
+ ),
+ ),
+ migrations.AlterField(
+ model_name="userevent",
+ name="ban_reason",
+ field=models.TextField(blank=True, help_text="Reason for ban"),
+ ),
+ migrations.AlterField(
+ model_name="userevent",
+ name="email_notifications",
+ field=models.BooleanField(
+ default=True, help_text="Whether to send email notifications"
+ ),
+ ),
+ migrations.AlterField(
+ model_name="userevent",
+ name="is_banned",
+ field=models.BooleanField(
+ default=False, help_text="Whether this user is banned"
+ ),
+ ),
+ migrations.AlterField(
+ model_name="userevent",
+ name="last_password_change",
+ field=models.DateTimeField(
+ auto_now_add=True, help_text="When the password was last changed"
+ ),
+ ),
+ migrations.AlterField(
+ model_name="userevent",
+ name="login_history_retention",
+ field=models.IntegerField(
+ default=90, help_text="How long to retain login history (days)"
+ ),
+ ),
+ migrations.AlterField(
+ model_name="userevent",
+ name="login_notifications",
+ field=models.BooleanField(
+ default=True, help_text="Whether to send login notifications"
+ ),
+ ),
+ migrations.AlterField(
+ model_name="userevent",
+ name="privacy_level",
+ field=apps.core.choices.fields.RichChoiceField(
+ allow_deprecated=False,
+ choice_group="privacy_levels",
+ choices=[
+ ("public", "Public"),
+ ("friends", "Friends Only"),
+ ("private", "Private"),
+ ],
+ default="public",
+ domain="accounts",
+ help_text="Overall privacy level",
+ max_length=10,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="userevent",
+ name="push_notifications",
+ field=models.BooleanField(
+ default=False, help_text="Whether to send push notifications"
+ ),
+ ),
+ migrations.AlterField(
+ model_name="userevent",
+ name="role",
+ field=apps.core.choices.fields.RichChoiceField(
+ allow_deprecated=False,
+ choice_group="user_roles",
+ choices=[
+ ("USER", "User"),
+ ("MODERATOR", "Moderator"),
+ ("ADMIN", "Admin"),
+ ("SUPERUSER", "Superuser"),
+ ],
+ default="USER",
+ domain="accounts",
+ help_text="User role (user, moderator, admin)",
+ max_length=10,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="userevent",
+ name="search_visibility",
+ field=models.BooleanField(
+ default=True, help_text="Whether profile appears in search results"
+ ),
+ ),
+ migrations.AlterField(
+ model_name="userevent",
+ name="session_timeout",
+ field=models.IntegerField(default=30, help_text="Session timeout in days"),
+ ),
+ migrations.AlterField(
+ model_name="userevent",
+ name="show_email",
+ field=models.BooleanField(
+ default=False, help_text="Whether to show email on profile"
+ ),
+ ),
+ migrations.AlterField(
+ model_name="userevent",
+ name="show_join_date",
+ field=models.BooleanField(
+ default=True, help_text="Whether to show join date on profile"
+ ),
+ ),
+ migrations.AlterField(
+ model_name="userevent",
+ name="show_photos",
+ field=models.BooleanField(
+ default=True, help_text="Whether to show photos on profile"
+ ),
+ ),
+ migrations.AlterField(
+ model_name="userevent",
+ name="show_real_name",
+ field=models.BooleanField(
+ default=True, help_text="Whether to show real name on profile"
+ ),
+ ),
+ migrations.AlterField(
+ model_name="userevent",
+ name="show_reviews",
+ field=models.BooleanField(
+ default=True, help_text="Whether to show reviews on profile"
+ ),
+ ),
+ migrations.AlterField(
+ model_name="userevent",
+ name="show_statistics",
+ field=models.BooleanField(
+ default=True, help_text="Whether to show statistics on profile"
+ ),
+ ),
+ migrations.AlterField(
+ model_name="userevent",
+ name="show_top_lists",
+ field=models.BooleanField(
+ default=True, help_text="Whether to show top lists on profile"
+ ),
+ ),
+ migrations.AlterField(
+ model_name="userevent",
+ name="theme_preference",
+ field=apps.core.choices.fields.RichChoiceField(
+ allow_deprecated=False,
+ choice_group="theme_preferences",
+ choices=[("light", "Light"), ("dark", "Dark")],
+ default="light",
+ domain="accounts",
+ help_text="User's theme preference (light/dark)",
+ max_length=5,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="userevent",
+ name="two_factor_enabled",
+ field=models.BooleanField(
+ default=False, help_text="Whether two-factor authentication is enabled"
+ ),
+ ),
+ migrations.AlterField(
+ model_name="usernotification",
+ name="content_type",
+ field=models.ForeignKey(
+ blank=True,
+ help_text="Type of related object",
+ null=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ to="contenttypes.contenttype",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="usernotification",
+ name="email_sent",
+ field=models.BooleanField(
+ default=False, help_text="Whether email was sent"
+ ),
+ ),
+ migrations.AlterField(
+ model_name="usernotification",
+ name="email_sent_at",
+ field=models.DateTimeField(
+ blank=True, help_text="When email was sent", null=True
+ ),
+ ),
+ migrations.AlterField(
+ model_name="usernotification",
+ name="is_read",
+ field=models.BooleanField(
+ default=False, help_text="Whether this notification has been read"
+ ),
+ ),
+ migrations.AlterField(
+ model_name="usernotification",
+ name="message",
+ field=models.TextField(help_text="Notification message"),
+ ),
+ migrations.AlterField(
+ model_name="usernotification",
+ name="object_id",
+ field=models.PositiveIntegerField(
+ blank=True, help_text="ID of related object", null=True
+ ),
+ ),
+ migrations.AlterField(
+ model_name="usernotification",
+ name="push_sent",
+ field=models.BooleanField(
+ default=False, help_text="Whether push notification was sent"
+ ),
+ ),
+ migrations.AlterField(
+ model_name="usernotification",
+ name="push_sent_at",
+ field=models.DateTimeField(
+ blank=True, help_text="When push notification was sent", null=True
+ ),
+ ),
+ migrations.AlterField(
+ model_name="usernotification",
+ name="read_at",
+ field=models.DateTimeField(
+ blank=True, help_text="When this notification was read", null=True
+ ),
+ ),
+ migrations.AlterField(
+ model_name="usernotification",
+ name="title",
+ field=models.CharField(help_text="Notification title", max_length=200),
+ ),
+ migrations.AlterField(
+ model_name="usernotification",
+ name="user",
+ field=models.ForeignKey(
+ help_text="User this notification is for",
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="notifications",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="usernotificationevent",
+ name="content_type",
+ field=models.ForeignKey(
+ blank=True,
+ db_constraint=False,
+ help_text="Type of related object",
+ null=True,
+ on_delete=django.db.models.deletion.DO_NOTHING,
+ related_name="+",
+ related_query_name="+",
+ to="contenttypes.contenttype",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="usernotificationevent",
+ name="email_sent",
+ field=models.BooleanField(
+ default=False, help_text="Whether email was sent"
+ ),
+ ),
+ migrations.AlterField(
+ model_name="usernotificationevent",
+ name="email_sent_at",
+ field=models.DateTimeField(
+ blank=True, help_text="When email was sent", null=True
+ ),
+ ),
+ migrations.AlterField(
+ model_name="usernotificationevent",
+ name="is_read",
+ field=models.BooleanField(
+ default=False, help_text="Whether this notification has been read"
+ ),
+ ),
+ migrations.AlterField(
+ model_name="usernotificationevent",
+ name="message",
+ field=models.TextField(help_text="Notification message"),
+ ),
+ migrations.AlterField(
+ model_name="usernotificationevent",
+ name="object_id",
+ field=models.PositiveIntegerField(
+ blank=True, help_text="ID of related object", null=True
+ ),
+ ),
+ migrations.AlterField(
+ model_name="usernotificationevent",
+ name="push_sent",
+ field=models.BooleanField(
+ default=False, help_text="Whether push notification was sent"
+ ),
+ ),
+ migrations.AlterField(
+ model_name="usernotificationevent",
+ name="push_sent_at",
+ field=models.DateTimeField(
+ blank=True, help_text="When push notification was sent", null=True
+ ),
+ ),
+ migrations.AlterField(
+ model_name="usernotificationevent",
+ name="read_at",
+ field=models.DateTimeField(
+ blank=True, help_text="When this notification was read", null=True
+ ),
+ ),
+ migrations.AlterField(
+ model_name="usernotificationevent",
+ name="title",
+ field=models.CharField(help_text="Notification title", max_length=200),
+ ),
+ migrations.AlterField(
+ model_name="usernotificationevent",
+ name="user",
+ field=models.ForeignKey(
+ db_constraint=False,
+ help_text="User this notification is for",
+ on_delete=django.db.models.deletion.DO_NOTHING,
+ related_name="+",
+ related_query_name="+",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="userprofile",
+ name="avatar",
+ field=models.ForeignKey(
+ blank=True,
+ help_text="User's avatar image",
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="user_profiles",
+ to="django_cloudflareimages_toolkit.cloudflareimage",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="userprofile",
+ name="bio",
+ field=models.TextField(
+ blank=True, help_text="User biography", max_length=500
+ ),
+ ),
+ migrations.AlterField(
+ model_name="userprofile",
+ name="coaster_credits",
+ field=models.IntegerField(
+ default=0, help_text="Number of roller coasters ridden"
+ ),
+ ),
+ migrations.AlterField(
+ model_name="userprofile",
+ name="dark_ride_credits",
+ field=models.IntegerField(
+ default=0, help_text="Number of dark rides ridden"
+ ),
+ ),
+ migrations.AlterField(
+ model_name="userprofile",
+ name="discord",
+ field=models.CharField(
+ blank=True, help_text="Discord username", max_length=100
+ ),
+ ),
+ migrations.AlterField(
+ model_name="userprofile",
+ name="flat_ride_credits",
+ field=models.IntegerField(
+ default=0, help_text="Number of flat rides ridden"
+ ),
+ ),
+ migrations.AlterField(
+ model_name="userprofile",
+ name="instagram",
+ field=models.URLField(blank=True, help_text="Instagram profile URL"),
+ ),
+ migrations.AlterField(
+ model_name="userprofile",
+ name="pronouns",
+ field=models.CharField(
+ blank=True, help_text="User's preferred pronouns", max_length=50
+ ),
+ ),
+ migrations.AlterField(
+ model_name="userprofile",
+ name="twitter",
+ field=models.URLField(blank=True, help_text="Twitter profile URL"),
+ ),
+ migrations.AlterField(
+ model_name="userprofile",
+ name="user",
+ field=models.OneToOneField(
+ help_text="User this profile belongs to",
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="profile",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="userprofile",
+ name="water_ride_credits",
+ field=models.IntegerField(
+ default=0, help_text="Number of water rides ridden"
+ ),
+ ),
+ migrations.AlterField(
+ model_name="userprofile",
+ name="youtube",
+ field=models.URLField(blank=True, help_text="YouTube channel URL"),
+ ),
+ migrations.AlterField(
+ model_name="userprofileevent",
+ name="avatar",
+ field=models.ForeignKey(
+ blank=True,
+ db_constraint=False,
+ help_text="User's avatar image",
+ null=True,
+ on_delete=django.db.models.deletion.DO_NOTHING,
+ related_name="+",
+ related_query_name="+",
+ to="django_cloudflareimages_toolkit.cloudflareimage",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="userprofileevent",
+ name="bio",
+ field=models.TextField(
+ blank=True, help_text="User biography", max_length=500
+ ),
+ ),
+ migrations.AlterField(
+ model_name="userprofileevent",
+ name="coaster_credits",
+ field=models.IntegerField(
+ default=0, help_text="Number of roller coasters ridden"
+ ),
+ ),
+ migrations.AlterField(
+ model_name="userprofileevent",
+ name="dark_ride_credits",
+ field=models.IntegerField(
+ default=0, help_text="Number of dark rides ridden"
+ ),
+ ),
+ migrations.AlterField(
+ model_name="userprofileevent",
+ name="discord",
+ field=models.CharField(
+ blank=True, help_text="Discord username", max_length=100
+ ),
+ ),
+ migrations.AlterField(
+ model_name="userprofileevent",
+ name="flat_ride_credits",
+ field=models.IntegerField(
+ default=0, help_text="Number of flat rides ridden"
+ ),
+ ),
+ migrations.AlterField(
+ model_name="userprofileevent",
+ name="instagram",
+ field=models.URLField(blank=True, help_text="Instagram profile URL"),
+ ),
+ migrations.AlterField(
+ model_name="userprofileevent",
+ name="pronouns",
+ field=models.CharField(
+ blank=True, help_text="User's preferred pronouns", max_length=50
+ ),
+ ),
+ migrations.AlterField(
+ model_name="userprofileevent",
+ name="twitter",
+ field=models.URLField(blank=True, help_text="Twitter profile URL"),
+ ),
+ migrations.AlterField(
+ model_name="userprofileevent",
+ name="user",
+ field=models.ForeignKey(
+ db_constraint=False,
+ help_text="User this profile belongs to",
+ on_delete=django.db.models.deletion.DO_NOTHING,
+ related_name="+",
+ related_query_name="+",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="userprofileevent",
+ name="water_ride_credits",
+ field=models.IntegerField(
+ default=0, help_text="Number of water rides ridden"
+ ),
+ ),
+ migrations.AlterField(
+ model_name="userprofileevent",
+ name="youtube",
+ field=models.URLField(blank=True, help_text="YouTube channel URL"),
+ ),
+ pgtrigger.migrations.AddTrigger(
+ model_name="userprofile",
+ trigger=pgtrigger.compiler.Trigger(
+ name="insert_insert",
+ sql=pgtrigger.compiler.UpsertTriggerSql(
+ func='INSERT INTO "accounts_userprofileevent" ("avatar_id", "bio", "coaster_credits", "dark_ride_credits", "discord", "display_name", "flat_ride_credits", "id", "instagram", "location", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "profile_id", "pronouns", "twitter", "unit_system", "user_id", "water_ride_credits", "youtube") VALUES (NEW."avatar_id", NEW."bio", NEW."coaster_credits", NEW."dark_ride_credits", NEW."discord", NEW."display_name", NEW."flat_ride_credits", NEW."id", NEW."instagram", NEW."location", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."profile_id", NEW."pronouns", NEW."twitter", NEW."unit_system", NEW."user_id", NEW."water_ride_credits", NEW."youtube"); RETURN NULL;',
+ hash="dab03867fefb6b82eec203906fe25f4e43d95783",
+ operation="INSERT",
+ pgid="pgtrigger_insert_insert_c09d7",
+ table="accounts_userprofile",
+ when="AFTER",
+ ),
+ ),
+ ),
+ pgtrigger.migrations.AddTrigger(
+ model_name="userprofile",
+ trigger=pgtrigger.compiler.Trigger(
+ name="update_update",
+ sql=pgtrigger.compiler.UpsertTriggerSql(
+ condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
+ func='INSERT INTO "accounts_userprofileevent" ("avatar_id", "bio", "coaster_credits", "dark_ride_credits", "discord", "display_name", "flat_ride_credits", "id", "instagram", "location", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "profile_id", "pronouns", "twitter", "unit_system", "user_id", "water_ride_credits", "youtube") VALUES (NEW."avatar_id", NEW."bio", NEW."coaster_credits", NEW."dark_ride_credits", NEW."discord", NEW."display_name", NEW."flat_ride_credits", NEW."id", NEW."instagram", NEW."location", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."profile_id", NEW."pronouns", NEW."twitter", NEW."unit_system", NEW."user_id", NEW."water_ride_credits", NEW."youtube"); RETURN NULL;',
+ hash="b70f93243f5852ae882f51a191d69bb3d3d151f7",
+ operation="UPDATE",
+ pgid="pgtrigger_update_update_87ef6",
+ table="accounts_userprofile",
+ when="AFTER",
+ ),
+ ),
+ ),
+ migrations.DeleteModel(
+ name="TopList",
+ ),
+ migrations.DeleteModel(
+ name="TopListItem",
+ ),
+ ]
diff --git a/backend/apps/accounts/models.py b/backend/apps/accounts/models.py
index dd3b0892..1b78d582 100644
--- a/backend/apps/accounts/models.py
+++ b/backend/apps/accounts/models.py
@@ -11,6 +11,7 @@ from django.utils import timezone
from apps.core.history import TrackedModel
from apps.core.choices import RichChoiceField
import pghistory
+# from django_cloudflareimages_toolkit.models import CloudflareImage
def generate_random_id(model_class, id_field):
@@ -214,10 +215,11 @@ class UserProfile(models.Model):
help_text="Legacy display name field - use User.display_name instead",
)
avatar = models.ForeignKey(
- 'django_cloudflareimages_toolkit.CloudflareImage',
+ "django_cloudflareimages_toolkit.CloudflareImage",
on_delete=models.SET_NULL,
null=True,
blank=True,
+ related_name="user_profiles",
help_text="User's avatar image",
)
pronouns = models.CharField(
@@ -225,6 +227,16 @@ class UserProfile(models.Model):
)
bio = models.TextField(max_length=500, blank=True, help_text="User biography")
+ location = models.CharField(
+ max_length=100, blank=True, help_text="User's location (City, Country)"
+ )
+ unit_system = RichChoiceField(
+ choice_group="unit_systems",
+ domain="accounts",
+ max_length=10,
+ default="metric",
+ help_text="Preferred measurement system",
+ )
# Social media links
twitter = models.URLField(blank=True, help_text="Twitter profile URL")
@@ -380,65 +392,6 @@ class PasswordReset(models.Model):
verbose_name_plural = "Password Resets"
-# @pghistory.track()
-
-
-class TopList(TrackedModel):
- user = models.ForeignKey(
- User,
- on_delete=models.CASCADE,
- related_name="top_lists",
- help_text="User who created this list",
- )
- title = models.CharField(max_length=100, help_text="Title of the top list")
- category = RichChoiceField(
- choice_group="top_list_categories",
- domain="accounts",
- max_length=2,
- help_text="Category of items in this list",
- )
- description = models.TextField(blank=True, help_text="Description of the list")
- created_at = models.DateTimeField(auto_now_add=True)
- updated_at = models.DateTimeField(auto_now=True)
-
- class Meta(TrackedModel.Meta):
- verbose_name = "Top List"
- verbose_name_plural = "Top Lists"
- ordering = ["-updated_at"]
-
- def __str__(self):
- return (
- f"{self.user.get_display_name()}'s {self.category} Top List: {self.title}"
- )
-
-
-# @pghistory.track()
-
-
-class TopListItem(TrackedModel):
- top_list = models.ForeignKey(
- TopList,
- on_delete=models.CASCADE,
- related_name="items",
- help_text="Top list this item belongs to",
- )
- content_type = models.ForeignKey(
- "contenttypes.ContentType",
- on_delete=models.CASCADE,
- help_text="Type of item (park, ride, etc.)",
- )
- object_id = models.PositiveIntegerField(help_text="ID of the item")
- rank = models.PositiveIntegerField(help_text="Position in the list")
- notes = models.TextField(blank=True, help_text="User's notes about this item")
-
- class Meta(TrackedModel.Meta):
- verbose_name = "Top List Item"
- verbose_name_plural = "Top List Items"
- ordering = ["rank"]
- unique_together = [["top_list", "rank"]]
-
- def __str__(self):
- return f"#{self.rank} in {self.top_list.title}"
@pghistory.track()
diff --git a/backend/apps/accounts/serializers.py b/backend/apps/accounts/serializers.py
index 495b056c..9a6a0e22 100644
--- a/backend/apps/accounts/serializers.py
+++ b/backend/apps/accounts/serializers.py
@@ -19,7 +19,9 @@ class UserSerializer(serializers.ModelSerializer):
"""
avatar_url = serializers.SerializerMethodField()
- display_name = serializers.SerializerMethodField()
+ display_name = serializers.CharField(source="profile.display_name", required=False)
+ unit_system = serializers.CharField(source="profile.unit_system", required=False)
+ location = serializers.CharField(source="profile.location", required=False)
class Meta:
model = User
@@ -31,6 +33,8 @@ class UserSerializer(serializers.ModelSerializer):
"date_joined",
"is_active",
"avatar_url",
+ "unit_system",
+ "location",
]
read_only_fields = ["id", "date_joined", "is_active"]
@@ -40,9 +44,15 @@ class UserSerializer(serializers.ModelSerializer):
return obj.profile.avatar.url
return None
- def get_display_name(self, obj) -> str:
- """Get user display name"""
- return obj.get_display_name()
+ def update(self, instance, validated_data):
+ profile_data = validated_data.pop("profile", {})
+ profile = instance.profile
+
+ for attr, value in profile_data.items():
+ setattr(profile, attr, value)
+ profile.save()
+
+ return super().update(instance, validated_data)
class LoginSerializer(serializers.Serializer):
diff --git a/backend/apps/accounts/views.py b/backend/apps/accounts/views.py
index 0ee8491a..4490faae 100644
--- a/backend/apps/accounts/views.py
+++ b/backend/apps/accounts/views.py
@@ -20,10 +20,10 @@ from django.core.files.uploadedfile import UploadedFile
from apps.accounts.models import (
User,
PasswordReset,
- TopList,
EmailVerification,
UserProfile,
)
+from apps.lists.models import UserList
from django_forwardemail.services import EmailService
from apps.parks.models import ParkReview
from apps.rides.models import RideReview
@@ -208,9 +208,9 @@ class ProfileView(DetailView):
.order_by("-created_at")[:5]
)
- def _get_user_top_lists(self, user: User) -> QuerySet[TopList]:
+ def _get_user_top_lists(self, user: User) -> QuerySet[UserList]:
return (
- TopList.objects.filter(user=user)
+ UserList.objects.filter(user=user)
.select_related("user", "user__profile")
.prefetch_related("items")
.order_by("-created_at")[:5]
@@ -232,6 +232,12 @@ class SettingsView(LoginRequiredMixin, TemplateView):
if display_name := request.POST.get("display_name"):
profile.display_name = display_name
+ if unit_system := request.POST.get("unit_system"):
+ profile.unit_system = unit_system
+
+ if location := request.POST.get("location"):
+ profile.location = location
+
if "avatar" in request.FILES:
avatar_file = cast(UploadedFile, request.FILES["avatar"])
profile.avatar.save(avatar_file.name, avatar_file, save=False)
diff --git a/backend/apps/api/v1/accounts/views.py b/backend/apps/api/v1/accounts/views.py
index 1a2e4a5d..fd43565f 100644
--- a/backend/apps/api/v1/accounts/views.py
+++ b/backend/apps/api/v1/accounts/views.py
@@ -13,7 +13,7 @@ from apps.api.v1.serializers.accounts import (
PrivacySettingsSerializer,
SecuritySettingsSerializer,
UserStatisticsSerializer,
- TopListSerializer,
+ UserListSerializer,
AccountUpdateSerializer,
ProfileUpdateSerializer,
ThemePreferenceSerializer,
@@ -26,10 +26,10 @@ from apps.accounts.services import UserDeletionService
from apps.accounts.models import (
User,
UserProfile,
- TopList,
UserNotification,
NotificationPreference,
)
+from apps.lists.models import UserList
import logging
from rest_framework import status
from rest_framework.decorators import api_view, permission_classes
@@ -831,7 +831,7 @@ def check_user_deletion_eligibility(request, user_id):
user, "uploaded_ride_photos", user.__class__.objects.none()
).count(),
"top_lists": getattr(
- user, "top_lists", user.__class__.objects.none()
+ user, "user_lists", user.__class__.objects.none()
).count(),
"edit_submissions": getattr(
user, "edit_submissions", user.__class__.objects.none()
@@ -1318,7 +1318,7 @@ def get_user_statistics(request):
"rides_ridden": RideReview.objects.filter(user=user).values("ride").distinct().count(),
"reviews_written": ParkReview.objects.filter(user=user).count() + RideReview.objects.filter(user=user).count(),
"photos_uploaded": total_photos_uploaded,
- "top_lists_created": TopList.objects.filter(user=user).count(),
+ "top_lists_created": UserList.objects.filter(user=user).count(),
"member_since": user.date_joined,
"last_activity": user.last_login,
}
@@ -1335,7 +1335,7 @@ def get_user_statistics(request):
summary="Get user's top lists",
description="Get all top lists created by the authenticated user.",
responses={
- 200: TopListSerializer(many=True),
+ 200: UserListSerializer(many=True),
401: {"description": "Authentication required"},
},
tags=["User Content"],
@@ -1344,8 +1344,8 @@ def get_user_statistics(request):
@permission_classes([IsAuthenticated])
def get_user_top_lists(request):
"""Get user's top lists."""
- top_lists = TopList.objects.filter(user=request.user).order_by("-created_at")
- serializer = TopListSerializer(top_lists, many=True)
+ top_lists = UserList.objects.filter(user=request.user).order_by("-created_at")
+ serializer = UserListSerializer(top_lists, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
@@ -1353,9 +1353,9 @@ def get_user_top_lists(request):
operation_id="create_top_list",
summary="Create a new top list",
description="Create a new top list for the authenticated user.",
- request=TopListSerializer,
+ request=UserListSerializer,
responses={
- 201: TopListSerializer,
+ 201: UserListSerializer,
400: {"description": "Validation error"},
},
tags=["User Content"],
@@ -1364,7 +1364,7 @@ def get_user_top_lists(request):
@permission_classes([IsAuthenticated])
def create_top_list(request):
"""Create a new top list."""
- serializer = TopListSerializer(data=request.data, context={"request": request})
+ serializer = UserListSerializer(data=request.data, context={"request": request})
if serializer.is_valid():
serializer.save(user=request.user)
@@ -1377,9 +1377,9 @@ def create_top_list(request):
operation_id="update_top_list",
summary="Update a top list",
description="Update a top list owned by the authenticated user.",
- request=TopListSerializer,
+ request=UserListSerializer,
responses={
- 200: TopListSerializer,
+ 200: UserListSerializer,
400: {"description": "Validation error"},
404: {"description": "Top list not found"},
},
@@ -1390,14 +1390,14 @@ def create_top_list(request):
def update_top_list(request, list_id):
"""Update a top list."""
try:
- top_list = TopList.objects.get(id=list_id, user=request.user)
- except TopList.DoesNotExist:
+ top_list = UserList.objects.get(id=list_id, user=request.user)
+ except UserList.DoesNotExist:
return Response(
{"error": "Top list not found"},
status=status.HTTP_404_NOT_FOUND
)
- serializer = TopListSerializer(
+ serializer = UserListSerializer(
top_list, data=request.data, partial=True, context={"request": request}
)
@@ -1423,10 +1423,10 @@ def update_top_list(request, list_id):
def delete_top_list(request, list_id):
"""Delete a top list."""
try:
- top_list = TopList.objects.get(id=list_id, user=request.user)
+ top_list = UserList.objects.get(id=list_id, user=request.user)
top_list.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
- except TopList.DoesNotExist:
+ except UserList.DoesNotExist:
return Response(
{"error": "Top list not found"},
status=status.HTTP_404_NOT_FOUND
diff --git a/backend/apps/api/v1/parks/park_views.py b/backend/apps/api/v1/parks/park_views.py
index a59c12ab..48fdc451 100644
--- a/backend/apps/api/v1/parks/park_views.py
+++ b/backend/apps/api/v1/parks/park_views.py
@@ -1081,3 +1081,45 @@ class ParkImageSettingsAPIView(APIView):
park, context={"request": request}
)
return Response(output_serializer.data)
+# --- Operator list ----------------------------------------------------------
+@extend_schema(
+ summary="List park operators",
+ description="List all companies with OPERATOR role, including park counts.",
+ responses={
+ 200: OpenApiTypes.OBJECT,
+ },
+ tags=["Parks"],
+)
+class OperatorListAPIView(APIView):
+ permission_classes = [permissions.AllowAny]
+
+ def get(self, request: Request) -> Response:
+ if not MODELS_AVAILABLE:
+ return Response(
+ {"detail": "Models not available"},
+ status=status.HTTP_501_NOT_IMPLEMENTED
+ )
+
+ operators = (
+ Company.objects.filter(roles__contains=["OPERATOR"])
+ .annotate(park_count=Count("operated_parks"))
+ .only("id", "name", "slug", "roles", "description")
+ .order_by("name")
+ )
+
+ # Simple serialization
+ data = [
+ {
+ "id": op.id,
+ "name": op.name,
+ "slug": op.slug,
+ "description": op.description,
+ "park_count": op.park_count,
+ }
+ for op in operators
+ ]
+
+ return Response({
+ "results": data,
+ "count": len(data)
+ })
diff --git a/backend/apps/api/v1/parks/urls.py b/backend/apps/api/v1/parks/urls.py
index e578e132..f32e295a 100644
--- a/backend/apps/api/v1/parks/urls.py
+++ b/backend/apps/api/v1/parks/urls.py
@@ -16,15 +16,24 @@ from .park_views import (
CompanySearchAPIView,
ParkSearchSuggestionsAPIView,
ParkImageSettingsAPIView,
+ OperatorListAPIView,
)
from .park_rides_views import (
ParkRidesListAPIView,
ParkRideDetailAPIView,
ParkComprehensiveDetailAPIView,
)
+from apps.parks.views import location_search, reverse_geocode
from .views import ParkPhotoViewSet, HybridParkAPIView, ParkFilterMetadataAPIView
from .ride_photos_views import RidePhotoViewSet
+from .ride_photos_views import RidePhotoViewSet
from .ride_reviews_views import RideReviewViewSet
+from apps.parks.views_roadtrip import (
+ CreateTripView,
+ FindParksAlongRouteView,
+ GeocodeAddressView,
+ ParkDistanceCalculatorView,
+)
# Create router for nested photo endpoints
router = DefaultRouter()
@@ -84,4 +93,19 @@ urlpatterns = [
# Nested ride review endpoints - reviews for specific rides within parks
path("/rides//reviews/", include(ride_reviews_router.urls)),
+ # Nested ride review endpoints - reviews for specific rides within parks
+ path("/rides//reviews/", include(ride_reviews_router.urls)),
+
+ # Roadtrip API endpoints
+ path("roadtrip/create/", CreateTripView.as_view(), name="roadtrip-create"),
+ path("roadtrip/find-along-route/", FindParksAlongRouteView.as_view(), name="roadtrip-find"),
+ path("roadtrip/geocode/", GeocodeAddressView.as_view(), name="roadtrip-geocode"),
+ path("roadtrip/distance/", ParkDistanceCalculatorView.as_view(), name="roadtrip-distance"),
+
+ # Operator endpoints
+ path("operators/", OperatorListAPIView.as_view(), name="operator-list"),
+
+ # Location search endpoints
+ path("search/location/", location_search, name="location-search"),
+ path("search/reverse-geocode/", reverse_geocode, name="reverse-geocode"),
]
diff --git a/backend/apps/api/v1/rides/urls.py b/backend/apps/api/v1/rides/urls.py
index 4de739ed..0e9c6dd0 100644
--- a/backend/apps/api/v1/rides/urls.py
+++ b/backend/apps/api/v1/rides/urls.py
@@ -21,6 +21,8 @@ from .views import (
RideImageSettingsAPIView,
HybridRideAPIView,
RideFilterMetadataAPIView,
+ ManufacturerListAPIView,
+ DesignerListAPIView,
)
from .photo_views import RidePhotoViewSet
@@ -56,6 +58,10 @@ urlpatterns = [
RideSearchSuggestionsAPIView.as_view(),
name="ride-search-suggestions",
),
+ # Manufacturer and Designer endpoints
+ path("manufacturers/", ManufacturerListAPIView.as_view(), name="manufacturer-list"),
+ path("designers/", DesignerListAPIView.as_view(), name="designer-list"),
+
# Ride model management endpoints - nested under rides/manufacturers
path(
"manufacturers//",
diff --git a/backend/apps/api/v1/rides/views.py b/backend/apps/api/v1/rides/views.py
index da29e8e2..5875eec6 100644
--- a/backend/apps/api/v1/rides/views.py
+++ b/backend/apps/api/v1/rides/views.py
@@ -2456,3 +2456,56 @@ class RideFilterMetadataAPIView(APIView):
# Reuse the same filter extraction logic
view = HybridRideAPIView()
return view._extract_filters(query_params)
+# === MANUFACTURER & DESIGNER LISTS ===
+
+class BaseCompanyListAPIView(APIView):
+ permission_classes = [permissions.AllowAny]
+ role = None
+
+ def get(self, request: Request) -> Response:
+ if not MODELS_AVAILABLE:
+ return Response(
+ {"detail": "Models not available"},
+ status=status.HTTP_501_NOT_IMPLEMENTED
+ )
+
+ companies = (
+ Company.objects.filter(roles__contains=[self.role])
+ .annotate(ride_count=Count("manufactured_rides" if self.role == "MANUFACTURER" else "designed_rides"))
+ .only("id", "name", "slug", "roles", "description")
+ .order_by("name")
+ )
+
+ data = [
+ {
+ "id": c.id,
+ "name": c.name,
+ "slug": c.slug,
+ "description": c.description,
+ "ride_count": c.ride_count,
+ }
+ for c in companies
+ ]
+
+ return Response({
+ "results": data,
+ "count": len(data)
+ })
+
+@extend_schema(
+ summary="List manufacturers",
+ description="List all companies with MANUFACTURER role.",
+ responses={200: OpenApiTypes.OBJECT},
+ tags=["Rides"],
+)
+class ManufacturerListAPIView(BaseCompanyListAPIView):
+ role = "MANUFACTURER"
+
+@extend_schema(
+ summary="List designers",
+ description="List all companies with DESIGNER role.",
+ responses={200: OpenApiTypes.OBJECT},
+ tags=["Rides"],
+)
+class DesignerListAPIView(BaseCompanyListAPIView):
+ role = "DESIGNER"
diff --git a/backend/apps/api/v1/serializers/accounts.py b/backend/apps/api/v1/serializers/accounts.py
index 2ce67449..6de6961f 100644
--- a/backend/apps/api/v1/serializers/accounts.py
+++ b/backend/apps/api/v1/serializers/accounts.py
@@ -14,10 +14,10 @@ from drf_spectacular.utils import (
from apps.accounts.models import (
User,
UserProfile,
- TopList,
UserNotification,
NotificationPreference,
)
+from apps.lists.models import UserList
from apps.core.choices.serializers import RichChoiceFieldSerializer
UserModel = get_user_model()
@@ -85,6 +85,8 @@ class UserProfileSerializer(serializers.ModelSerializer):
"dark_ride_credits",
"flat_ride_credits",
"water_ride_credits",
+ "unit_system",
+ "location",
]
read_only_fields = ["profile_id", "avatar_url", "avatar_variants"]
@@ -503,8 +505,8 @@ class UserStatisticsSerializer(serializers.Serializer):
@extend_schema_serializer(
examples=[
OpenApiExample(
- "Top List Example",
- summary="User's top list",
+ "User List Example",
+ summary="User's list",
description="A user's ranked list of rides or parks",
value={
"id": 1,
@@ -518,13 +520,13 @@ class UserStatisticsSerializer(serializers.Serializer):
)
]
)
-class TopListSerializer(serializers.ModelSerializer):
- """Serializer for user's top lists."""
+class UserListSerializer(serializers.ModelSerializer):
+ """Serializer for user's lists."""
items_count = serializers.SerializerMethodField()
class Meta:
- model = TopList
+ model = UserList
fields = [
"id",
"title",
@@ -611,6 +613,8 @@ class ProfileUpdateSerializer(serializers.ModelSerializer):
"instagram",
"youtube",
"discord",
+ "unit_system",
+ "location",
]
def validate_display_name(self, value):
diff --git a/backend/apps/api/v1/urls.py b/backend/apps/api/v1/urls.py
index 6a9cab23..b916845a 100644
--- a/backend/apps/api/v1/urls.py
+++ b/backend/apps/api/v1/urls.py
@@ -73,6 +73,7 @@ urlpatterns = [
path("email/", include("apps.api.v1.email.urls")),
path("core/", include("apps.api.v1.core.urls")),
path("maps/", include("apps.api.v1.maps.urls")),
+ path("lists/", include("apps.lists.urls")),
path("moderation/", include("apps.moderation.urls")),
# Cloudflare Images Toolkit API endpoints
path("cloudflare-images/", include("django_cloudflareimages_toolkit.urls")),
diff --git a/backend/apps/core/migrations/0004_alter_slughistory_options_and_more.py b/backend/apps/core/migrations/0004_alter_slughistory_options_and_more.py
new file mode 100644
index 00000000..8bc33fc9
--- /dev/null
+++ b/backend/apps/core/migrations/0004_alter_slughistory_options_and_more.py
@@ -0,0 +1,70 @@
+# Generated by Django 5.1.6 on 2025-12-26 14:10
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("contenttypes", "0002_remove_content_type_name"),
+ ("core", "0003_pageviewevent_slughistoryevent_and_more"),
+ ]
+
+ operations = [
+ migrations.AlterModelOptions(
+ name="slughistory",
+ options={
+ "ordering": ["-created_at"],
+ "verbose_name": "Slug History",
+ "verbose_name_plural": "Slug Histories",
+ },
+ ),
+ migrations.AlterField(
+ model_name="slughistory",
+ name="content_type",
+ field=models.ForeignKey(
+ help_text="Type of model this slug belongs to",
+ on_delete=django.db.models.deletion.CASCADE,
+ to="contenttypes.contenttype",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="slughistory",
+ name="object_id",
+ field=models.CharField(
+ help_text="ID of the object this slug belongs to", max_length=50
+ ),
+ ),
+ migrations.AlterField(
+ model_name="slughistory",
+ name="old_slug",
+ field=models.SlugField(help_text="Previous slug value", max_length=200),
+ ),
+ migrations.AlterField(
+ model_name="slughistoryevent",
+ name="content_type",
+ field=models.ForeignKey(
+ db_constraint=False,
+ help_text="Type of model this slug belongs to",
+ on_delete=django.db.models.deletion.DO_NOTHING,
+ related_name="+",
+ related_query_name="+",
+ to="contenttypes.contenttype",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="slughistoryevent",
+ name="object_id",
+ field=models.CharField(
+ help_text="ID of the object this slug belongs to", max_length=50
+ ),
+ ),
+ migrations.AlterField(
+ model_name="slughistoryevent",
+ name="old_slug",
+ field=models.SlugField(
+ db_index=False, help_text="Previous slug value", max_length=200
+ ),
+ ),
+ ]
diff --git a/backend/apps/core/permissions.py b/backend/apps/core/permissions.py
new file mode 100644
index 00000000..8180290c
--- /dev/null
+++ b/backend/apps/core/permissions.py
@@ -0,0 +1,18 @@
+from rest_framework import permissions
+
+class IsOwnerOrReadOnly(permissions.BasePermission):
+ """
+ Custom permission to only allow owners of an object to edit it.
+ """
+
+ def has_object_permission(self, request, view, obj):
+ # Read permissions are allowed to any request,
+ # so we'll always allow GET, HEAD or OPTIONS requests.
+ if request.method in permissions.SAFE_METHODS:
+ return True
+
+ # Write permissions are only allowed to the owner of the object.
+ # Assumes the model instance has an `user` attribute.
+ if hasattr(obj, 'user'):
+ return obj.user == request.user
+ return False
diff --git a/backend/apps/lists/__init__.py b/backend/apps/lists/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/backend/apps/lists/admin.py b/backend/apps/lists/admin.py
new file mode 100644
index 00000000..ceaff2ea
--- /dev/null
+++ b/backend/apps/lists/admin.py
@@ -0,0 +1,90 @@
+from django.contrib import admin
+from django.db.models import Count
+from django.utils.html import format_html
+from apps.core.admin import (
+ BaseModelAdmin,
+ ExportActionMixin,
+ QueryOptimizationMixin,
+ TimestampFieldsMixin,
+)
+from .models import UserList, ListItem
+
+
+class ListItemInline(admin.TabularInline):
+ """Inline admin for ListItem within UserList admin."""
+ model = ListItem
+ extra = 1
+ fields = ("content_type", "object_id", "rank", "notes")
+ ordering = ("rank",)
+ show_change_link = True
+
+
+@admin.register(UserList)
+class UserListAdmin(QueryOptimizationMixin, ExportActionMixin, TimestampFieldsMixin, BaseModelAdmin):
+ """Admin interface for UserList."""
+ list_display = (
+ "title",
+ "user_link",
+ "category",
+ "is_public",
+ "item_count",
+ "created_at",
+ "updated_at",
+ )
+ list_filter = ("category", "is_public", "created_at", "updated_at")
+ list_select_related = ["user"]
+ list_prefetch_related = ["items"]
+ search_fields = ("title", "user__username", "description")
+ autocomplete_fields = ["user"]
+ inlines = [ListItemInline]
+
+ export_fields = ["id", "title", "user", "category", "is_public", "created_at", "updated_at"]
+ export_filename_prefix = "user_lists"
+
+ fieldsets = (
+ (
+ "Basic Information",
+ {
+ "fields": ("user", "title", "category", "description", "is_public"),
+ "description": "List identification and categorization.",
+ },
+ ),
+ (
+ "Timestamps",
+ {
+ "fields": ("created_at", "updated_at"),
+ "classes": ("collapse",),
+ },
+ ),
+ )
+ readonly_fields = ("created_at", "updated_at")
+
+ @admin.display(description="User")
+ def user_link(self, obj):
+ if obj.user:
+ from django.urls import reverse
+ url = reverse("admin:accounts_customuser_change", args=[obj.user.pk])
+ return format_html('{}', url, obj.user.username)
+ return "-"
+
+ @admin.display(description="Items")
+ def item_count(self, obj):
+ return obj.items.count()
+
+ def get_queryset(self, request):
+ qs = super().get_queryset(request)
+ qs = qs.annotate(_item_count=Count("items", distinct=True))
+ return qs
+
+
+@admin.register(ListItem)
+class ListItemAdmin(QueryOptimizationMixin, BaseModelAdmin):
+ """Admin interface for ListItem."""
+ list_display = (
+ "user_list",
+ "content_type",
+ "object_id",
+ "rank",
+ )
+ list_filter = ("user_list__category", "content_type", "rank")
+ list_select_related = ["user_list", "user_list__user", "content_type"]
diff --git a/backend/apps/lists/apps.py b/backend/apps/lists/apps.py
new file mode 100644
index 00000000..c86f2bae
--- /dev/null
+++ b/backend/apps/lists/apps.py
@@ -0,0 +1,5 @@
+from django.apps import AppConfig
+
+class ListsConfig(AppConfig):
+ default_auto_field = "django.db.models.BigAutoField"
+ name = "apps.lists"
diff --git a/backend/apps/lists/migrations/0001_initial.py b/backend/apps/lists/migrations/0001_initial.py
new file mode 100644
index 00000000..d9715433
--- /dev/null
+++ b/backend/apps/lists/migrations/0001_initial.py
@@ -0,0 +1,284 @@
+# Generated by Django 5.1.6 on 2025-12-26 14:13
+
+import apps.core.choices.fields
+import django.db.models.deletion
+import pgtrigger.compiler
+import pgtrigger.migrations
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ("contenttypes", "0002_remove_content_type_name"),
+ ("pghistory", "0006_delete_aggregateevent"),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="ListItem",
+ 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.PositiveIntegerField(help_text="ID of the item")),
+ ("rank", models.PositiveIntegerField(help_text="Position in the list")),
+ ("notes", models.TextField(blank=True, help_text="User's notes about this item")),
+ (
+ "content_type",
+ models.ForeignKey(
+ help_text="Type of item (park, ride, etc.)",
+ on_delete=django.db.models.deletion.CASCADE,
+ to="contenttypes.contenttype",
+ ),
+ ),
+ ],
+ options={
+ "verbose_name": "List Item",
+ "verbose_name_plural": "List Items",
+ "ordering": ["rank"],
+ "abstract": False,
+ },
+ ),
+ migrations.CreateModel(
+ name="UserList",
+ 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 of the list", max_length=100)),
+ (
+ "category",
+ apps.core.choices.fields.RichChoiceField(
+ allow_deprecated=False,
+ choice_group="top_list_categories",
+ choices=[
+ ("RC", "Roller Coaster"),
+ ("DR", "Dark Ride"),
+ ("FR", "Flat Ride"),
+ ("WR", "Water Ride"),
+ ("PK", "Park"),
+ ],
+ domain="accounts",
+ help_text="Category of items in this list",
+ max_length=2,
+ ),
+ ),
+ ("description", models.TextField(blank=True, help_text="Description of the list")),
+ ("is_public", models.BooleanField(default=True, help_text="Whether this list is visible to others")),
+ (
+ "user",
+ models.ForeignKey(
+ help_text="User who created this list",
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="user_lists",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ ],
+ options={
+ "verbose_name": "User List",
+ "verbose_name_plural": "User Lists",
+ "ordering": ["-updated_at"],
+ "abstract": False,
+ },
+ ),
+ migrations.CreateModel(
+ name="ListItemEvent",
+ 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)),
+ ("object_id", models.PositiveIntegerField(help_text="ID of the item")),
+ ("rank", models.PositiveIntegerField(help_text="Position in the list")),
+ ("notes", models.TextField(blank=True, help_text="User's notes about this item")),
+ (
+ "content_type",
+ models.ForeignKey(
+ db_constraint=False,
+ help_text="Type of item (park, ride, etc.)",
+ on_delete=django.db.models.deletion.DO_NOTHING,
+ related_name="+",
+ related_query_name="+",
+ to="contenttypes.contenttype",
+ ),
+ ),
+ (
+ "pgh_context",
+ models.ForeignKey(
+ db_constraint=False,
+ null=True,
+ on_delete=django.db.models.deletion.DO_NOTHING,
+ related_name="+",
+ to="pghistory.context",
+ ),
+ ),
+ (
+ "pgh_obj",
+ models.ForeignKey(
+ db_constraint=False,
+ on_delete=django.db.models.deletion.DO_NOTHING,
+ related_name="events",
+ to="lists.listitem",
+ ),
+ ),
+ (
+ "user_list",
+ models.ForeignKey(
+ db_constraint=False,
+ help_text="List this item belongs to",
+ on_delete=django.db.models.deletion.DO_NOTHING,
+ related_name="+",
+ related_query_name="+",
+ to="lists.userlist",
+ ),
+ ),
+ ],
+ options={
+ "abstract": False,
+ },
+ ),
+ migrations.AddField(
+ model_name="listitem",
+ name="user_list",
+ field=models.ForeignKey(
+ help_text="List this item belongs to",
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="items",
+ to="lists.userlist",
+ ),
+ ),
+ migrations.CreateModel(
+ name="UserListEvent",
+ 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 of the list", max_length=100)),
+ (
+ "category",
+ apps.core.choices.fields.RichChoiceField(
+ allow_deprecated=False,
+ choice_group="top_list_categories",
+ choices=[
+ ("RC", "Roller Coaster"),
+ ("DR", "Dark Ride"),
+ ("FR", "Flat Ride"),
+ ("WR", "Water Ride"),
+ ("PK", "Park"),
+ ],
+ domain="accounts",
+ help_text="Category of items in this list",
+ max_length=2,
+ ),
+ ),
+ ("description", models.TextField(blank=True, help_text="Description of the list")),
+ ("is_public", models.BooleanField(default=True, help_text="Whether this list is visible to others")),
+ (
+ "pgh_context",
+ models.ForeignKey(
+ db_constraint=False,
+ null=True,
+ on_delete=django.db.models.deletion.DO_NOTHING,
+ related_name="+",
+ to="pghistory.context",
+ ),
+ ),
+ (
+ "pgh_obj",
+ models.ForeignKey(
+ db_constraint=False,
+ on_delete=django.db.models.deletion.DO_NOTHING,
+ related_name="events",
+ to="lists.userlist",
+ ),
+ ),
+ (
+ "user",
+ models.ForeignKey(
+ db_constraint=False,
+ help_text="User who created this list",
+ on_delete=django.db.models.deletion.DO_NOTHING,
+ related_name="+",
+ related_query_name="+",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ ],
+ options={
+ "abstract": False,
+ },
+ ),
+ pgtrigger.migrations.AddTrigger(
+ model_name="userlist",
+ trigger=pgtrigger.compiler.Trigger(
+ name="insert_insert",
+ sql=pgtrigger.compiler.UpsertTriggerSql(
+ func='INSERT INTO "lists_userlistevent" ("category", "created_at", "description", "id", "is_public", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "title", "updated_at", "user_id") VALUES (NEW."category", NEW."created_at", NEW."description", NEW."id", NEW."is_public", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."title", NEW."updated_at", NEW."user_id"); RETURN NULL;',
+ hash="702082b0a9ed526aa1bffbec0839e9a2d7641f42",
+ operation="INSERT",
+ pgid="pgtrigger_insert_insert_7a128",
+ table="lists_userlist",
+ when="AFTER",
+ ),
+ ),
+ ),
+ pgtrigger.migrations.AddTrigger(
+ model_name="userlist",
+ trigger=pgtrigger.compiler.Trigger(
+ name="update_update",
+ sql=pgtrigger.compiler.UpsertTriggerSql(
+ condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
+ func='INSERT INTO "lists_userlistevent" ("category", "created_at", "description", "id", "is_public", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "title", "updated_at", "user_id") VALUES (NEW."category", NEW."created_at", NEW."description", NEW."id", NEW."is_public", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."title", NEW."updated_at", NEW."user_id"); RETURN NULL;',
+ hash="843e25a795f48bb1dfbb3c5723598823a71e0da8",
+ operation="UPDATE",
+ pgid="pgtrigger_update_update_1d718",
+ table="lists_userlist",
+ when="AFTER",
+ ),
+ ),
+ ),
+ migrations.AlterUniqueTogether(
+ name="listitem",
+ unique_together={("user_list", "rank")},
+ ),
+ pgtrigger.migrations.AddTrigger(
+ model_name="listitem",
+ trigger=pgtrigger.compiler.Trigger(
+ name="insert_insert",
+ sql=pgtrigger.compiler.UpsertTriggerSql(
+ func='INSERT INTO "lists_listitemevent" ("content_type_id", "created_at", "id", "notes", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "rank", "updated_at", "user_list_id") VALUES (NEW."content_type_id", NEW."created_at", NEW."id", NEW."notes", NEW."object_id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."rank", NEW."updated_at", NEW."user_list_id"); RETURN NULL;',
+ hash="09893103c0995cb295cdf83421583a93266593bb",
+ operation="INSERT",
+ pgid="pgtrigger_insert_insert_bb169",
+ table="lists_listitem",
+ when="AFTER",
+ ),
+ ),
+ ),
+ pgtrigger.migrations.AddTrigger(
+ model_name="listitem",
+ trigger=pgtrigger.compiler.Trigger(
+ name="update_update",
+ sql=pgtrigger.compiler.UpsertTriggerSql(
+ condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
+ func='INSERT INTO "lists_listitemevent" ("content_type_id", "created_at", "id", "notes", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "rank", "updated_at", "user_list_id") VALUES (NEW."content_type_id", NEW."created_at", NEW."id", NEW."notes", NEW."object_id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."rank", NEW."updated_at", NEW."user_list_id"); RETURN NULL;',
+ hash="5617f50c7404a18a24f08bd237aecd466b496339",
+ operation="UPDATE",
+ pgid="pgtrigger_update_update_2b5a0",
+ table="lists_listitem",
+ when="AFTER",
+ ),
+ ),
+ ),
+ ]
diff --git a/backend/apps/lists/migrations/__init__.py b/backend/apps/lists/migrations/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/backend/apps/lists/models.py b/backend/apps/lists/models.py
new file mode 100644
index 00000000..0ff42152
--- /dev/null
+++ b/backend/apps/lists/models.py
@@ -0,0 +1,61 @@
+from django.db import models
+from django.conf import settings
+from django.contrib.contenttypes.fields import GenericForeignKey
+from django.contrib.contenttypes.models import ContentType
+from apps.core.history import TrackedModel
+from apps.core.choices import RichChoiceField
+import pghistory
+
+@pghistory.track()
+class UserList(TrackedModel):
+ user = models.ForeignKey(
+ settings.AUTH_USER_MODEL,
+ on_delete=models.CASCADE,
+ related_name="user_lists",
+ help_text="User who created this list",
+ )
+ title = models.CharField(max_length=100, help_text="Title of the list")
+ category = RichChoiceField(
+ choice_group="top_list_categories",
+ domain="accounts",
+ max_length=2,
+ help_text="Category of items in this list",
+ )
+ description = models.TextField(blank=True, help_text="Description of the list")
+ is_public = models.BooleanField(default=True, help_text="Whether this list is visible to others")
+
+ class Meta(TrackedModel.Meta):
+ verbose_name = "User List"
+ verbose_name_plural = "User Lists"
+ ordering = ["-updated_at"]
+
+ def __str__(self):
+ return f"{self.user.username}'s {self.category} List: {self.title}"
+
+
+@pghistory.track()
+class ListItem(TrackedModel):
+ user_list = models.ForeignKey(
+ UserList,
+ on_delete=models.CASCADE,
+ related_name="items",
+ help_text="List this item belongs to",
+ )
+ content_type = models.ForeignKey(
+ ContentType,
+ on_delete=models.CASCADE,
+ help_text="Type of item (park, ride, etc.)",
+ )
+ object_id = models.PositiveIntegerField(help_text="ID of the item")
+ content_object = GenericForeignKey("content_type", "object_id")
+ rank = models.PositiveIntegerField(help_text="Position in the list")
+ notes = models.TextField(blank=True, help_text="User's notes about this item")
+
+ class Meta(TrackedModel.Meta):
+ verbose_name = "List Item"
+ verbose_name_plural = "List Items"
+ ordering = ["rank"]
+ unique_together = [["user_list", "rank"]]
+
+ def __str__(self):
+ return f"#{self.rank} in {self.user_list.title}"
diff --git a/backend/apps/lists/serializers.py b/backend/apps/lists/serializers.py
new file mode 100644
index 00000000..400c168b
--- /dev/null
+++ b/backend/apps/lists/serializers.py
@@ -0,0 +1,57 @@
+from rest_framework import serializers
+from .models import UserList, ListItem
+from apps.accounts.serializers import UserSerializer
+
+class ListItemSerializer(serializers.ModelSerializer):
+ class Meta:
+ model = ListItem
+ fields = [
+ "id",
+ "user_list",
+ "content_type",
+ "object_id",
+ "rank",
+ "notes",
+ "created_at",
+ "updated_at",
+ "content_object_data",
+ ]
+ read_only_fields = ["id", "created_at", "updated_at"]
+
+ content_object_data = serializers.SerializerMethodField()
+
+ def get_content_object_data(self, obj):
+ """
+ Return serialized data for the content object (Park or Ride).
+ """
+ # Avoid circular imports
+ from apps.api.v1.parks.serializers import ParkListSerializer
+ from apps.api.v1.rides.serializers import RideListSerializer
+ from apps.parks.models import Park
+ from apps.rides.models import Ride
+
+ if isinstance(obj.content_object, Park):
+ return ParkListSerializer(obj.content_object, context=self.context).data
+ elif isinstance(obj.content_object, Ride):
+ return RideListSerializer(obj.content_object, context=self.context).data
+ return None
+
+
+class UserListSerializer(serializers.ModelSerializer):
+ user = UserSerializer(read_only=True)
+ items = ListItemSerializer(many=True, read_only=True)
+
+ class Meta:
+ model = UserList
+ fields = [
+ "id",
+ "user",
+ "title",
+ "category",
+ "description",
+ "is_public",
+ "items",
+ "created_at",
+ "updated_at",
+ ]
+ read_only_fields = ["id", "user", "created_at", "updated_at"]
diff --git a/backend/apps/lists/urls.py b/backend/apps/lists/urls.py
new file mode 100644
index 00000000..9a0f036c
--- /dev/null
+++ b/backend/apps/lists/urls.py
@@ -0,0 +1,11 @@
+from django.urls import path, include
+from rest_framework.routers import DefaultRouter
+from .views import UserListViewSet, ListItemViewSet
+
+router = DefaultRouter()
+router.register(r"lists", UserListViewSet, basename="list")
+router.register(r"list-items", ListItemViewSet, basename="list-item")
+
+urlpatterns = [
+ path("", include(router.urls)),
+]
diff --git a/backend/apps/lists/views.py b/backend/apps/lists/views.py
new file mode 100644
index 00000000..6584f58a
--- /dev/null
+++ b/backend/apps/lists/views.py
@@ -0,0 +1,28 @@
+from django.db.models import Q
+from rest_framework import viewsets, permissions
+from .models import UserList, ListItem
+from .serializers import UserListSerializer, ListItemSerializer
+from apps.core.permissions import IsOwnerOrReadOnly
+
+class UserListViewSet(viewsets.ModelViewSet):
+ serializer_class = UserListSerializer
+ permission_classes = [permissions.IsAuthenticatedOrReadOnly, IsOwnerOrReadOnly]
+ lookup_field = "id"
+
+ def get_queryset(self):
+ # Users can see their own lists and public lists
+ if self.request.user.is_authenticated:
+ return UserList.objects.filter(Q(is_public=True) | Q(user=self.request.user))
+ return UserList.objects.filter(is_public=True)
+
+ def perform_create(self, serializer):
+ serializer.save(user=self.request.user)
+
+
+class ListItemViewSet(viewsets.ModelViewSet):
+ serializer_class = ListItemSerializer
+ permission_classes = [permissions.IsAuthenticatedOrReadOnly, IsOwnerOrReadOnly]
+ lookup_field = "id"
+
+ def get_queryset(self):
+ return ListItem.objects.filter(user_list__is_public=True) | ListItem.objects.filter(user_list__user=self.request.user)
diff --git a/backend/apps/moderation/migrations/0008_alter_bulkoperation_options_and_more.py b/backend/apps/moderation/migrations/0008_alter_bulkoperation_options_and_more.py
new file mode 100644
index 00000000..e7a8a35f
--- /dev/null
+++ b/backend/apps/moderation/migrations/0008_alter_bulkoperation_options_and_more.py
@@ -0,0 +1,750 @@
+# Generated by Django 5.1.6 on 2025-12-26 14:10
+
+import apps.core.state_machine.fields
+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"),
+ ("moderation", "0007_convert_status_to_richfsmfield"),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.AlterModelOptions(
+ name="bulkoperation",
+ options={
+ "ordering": ["-created_at"],
+ "verbose_name": "Bulk Operation",
+ "verbose_name_plural": "Bulk Operations",
+ },
+ ),
+ migrations.AlterModelOptions(
+ name="editsubmission",
+ options={
+ "ordering": ["-created_at"],
+ "verbose_name": "Edit Submission",
+ "verbose_name_plural": "Edit Submissions",
+ },
+ ),
+ migrations.AlterModelOptions(
+ name="moderationaction",
+ options={
+ "ordering": ["-created_at"],
+ "verbose_name": "Moderation Action",
+ "verbose_name_plural": "Moderation Actions",
+ },
+ ),
+ migrations.AlterModelOptions(
+ name="moderationqueue",
+ options={
+ "ordering": ["priority", "created_at"],
+ "verbose_name": "Moderation Queue Item",
+ "verbose_name_plural": "Moderation Queue Items",
+ },
+ ),
+ migrations.AlterModelOptions(
+ name="moderationreport",
+ options={
+ "ordering": ["-created_at"],
+ "verbose_name": "Moderation Report",
+ "verbose_name_plural": "Moderation Reports",
+ },
+ ),
+ migrations.AlterModelOptions(
+ name="photosubmission",
+ options={
+ "ordering": ["-created_at"],
+ "verbose_name": "Photo Submission",
+ "verbose_name_plural": "Photo Submissions",
+ },
+ ),
+ migrations.AlterField(
+ model_name="bulkoperation",
+ name="completed_at",
+ field=models.DateTimeField(
+ blank=True, help_text="When this operation completed", null=True
+ ),
+ ),
+ migrations.AlterField(
+ model_name="bulkoperation",
+ name="created_by",
+ field=models.ForeignKey(
+ help_text="User who created this operation",
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="bulk_operations_created",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="bulkoperation",
+ name="started_at",
+ field=models.DateTimeField(
+ blank=True, help_text="When this operation started", null=True
+ ),
+ ),
+ migrations.AlterField(
+ model_name="bulkoperation",
+ name="updated_at",
+ field=models.DateTimeField(
+ auto_now=True, help_text="When this operation was last updated"
+ ),
+ ),
+ migrations.AlterField(
+ model_name="bulkoperationevent",
+ name="completed_at",
+ field=models.DateTimeField(
+ blank=True, help_text="When this operation completed", null=True
+ ),
+ ),
+ migrations.AlterField(
+ model_name="bulkoperationevent",
+ name="created_by",
+ field=models.ForeignKey(
+ db_constraint=False,
+ help_text="User who created this operation",
+ on_delete=django.db.models.deletion.DO_NOTHING,
+ related_name="+",
+ related_query_name="+",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="bulkoperationevent",
+ name="started_at",
+ field=models.DateTimeField(
+ blank=True, help_text="When this operation started", null=True
+ ),
+ ),
+ migrations.AlterField(
+ model_name="bulkoperationevent",
+ name="status",
+ field=apps.core.state_machine.fields.RichFSMField(
+ allow_deprecated=False,
+ choice_group="bulk_operation_statuses",
+ choices=[
+ ("PENDING", "Pending"),
+ ("RUNNING", "Running"),
+ ("COMPLETED", "Completed"),
+ ("FAILED", "Failed"),
+ ("CANCELLED", "Cancelled"),
+ ],
+ default="PENDING",
+ domain="moderation",
+ max_length=20,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="bulkoperationevent",
+ name="updated_at",
+ field=models.DateTimeField(
+ auto_now=True, help_text="When this operation was last updated"
+ ),
+ ),
+ migrations.AlterField(
+ model_name="editsubmission",
+ name="content_type",
+ field=models.ForeignKey(
+ help_text="Type of object being edited",
+ on_delete=django.db.models.deletion.CASCADE,
+ to="contenttypes.contenttype",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="editsubmission",
+ name="handled_at",
+ field=models.DateTimeField(
+ blank=True, help_text="When this submission was handled", null=True
+ ),
+ ),
+ migrations.AlterField(
+ model_name="editsubmission",
+ name="handled_by",
+ field=models.ForeignKey(
+ blank=True,
+ help_text="Moderator who handled this submission",
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="handled_submissions",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="editsubmission",
+ name="object_id",
+ field=models.PositiveIntegerField(
+ blank=True,
+ help_text="ID of object being edited (null for new objects)",
+ null=True,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="editsubmission",
+ name="user",
+ field=models.ForeignKey(
+ help_text="User who submitted this edit",
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="edit_submissions",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="editsubmissionevent",
+ name="content_type",
+ field=models.ForeignKey(
+ db_constraint=False,
+ help_text="Type of object being edited",
+ on_delete=django.db.models.deletion.DO_NOTHING,
+ related_name="+",
+ related_query_name="+",
+ to="contenttypes.contenttype",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="editsubmissionevent",
+ name="handled_at",
+ field=models.DateTimeField(
+ blank=True, help_text="When this submission was handled", null=True
+ ),
+ ),
+ migrations.AlterField(
+ model_name="editsubmissionevent",
+ name="handled_by",
+ field=models.ForeignKey(
+ blank=True,
+ db_constraint=False,
+ help_text="Moderator who handled this submission",
+ null=True,
+ on_delete=django.db.models.deletion.DO_NOTHING,
+ related_name="+",
+ related_query_name="+",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="editsubmissionevent",
+ name="object_id",
+ field=models.PositiveIntegerField(
+ blank=True,
+ help_text="ID of object being edited (null for new objects)",
+ null=True,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="editsubmissionevent",
+ name="status",
+ field=apps.core.state_machine.fields.RichFSMField(
+ allow_deprecated=False,
+ choice_group="edit_submission_statuses",
+ choices=[
+ ("PENDING", "Pending"),
+ ("APPROVED", "Approved"),
+ ("REJECTED", "Rejected"),
+ ("ESCALATED", "Escalated"),
+ ],
+ default="PENDING",
+ domain="moderation",
+ max_length=20,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="editsubmissionevent",
+ name="user",
+ field=models.ForeignKey(
+ db_constraint=False,
+ help_text="User who submitted this edit",
+ on_delete=django.db.models.deletion.DO_NOTHING,
+ related_name="+",
+ related_query_name="+",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="moderationaction",
+ name="created_at",
+ field=models.DateTimeField(
+ auto_now_add=True, help_text="When this action was created"
+ ),
+ ),
+ migrations.AlterField(
+ model_name="moderationaction",
+ name="moderator",
+ field=models.ForeignKey(
+ help_text="Moderator who took this action",
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="moderation_actions_taken",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="moderationaction",
+ name="related_report",
+ field=models.ForeignKey(
+ blank=True,
+ help_text="Related moderation report",
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="actions_taken",
+ to="moderation.moderationreport",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="moderationaction",
+ name="target_user",
+ field=models.ForeignKey(
+ help_text="User this action was taken against",
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="moderation_actions_received",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="moderationaction",
+ name="updated_at",
+ field=models.DateTimeField(
+ auto_now=True, help_text="When this action was last updated"
+ ),
+ ),
+ migrations.AlterField(
+ model_name="moderationactionevent",
+ name="created_at",
+ field=models.DateTimeField(
+ auto_now_add=True, help_text="When this action was created"
+ ),
+ ),
+ migrations.AlterField(
+ model_name="moderationactionevent",
+ name="moderator",
+ field=models.ForeignKey(
+ db_constraint=False,
+ help_text="Moderator who took this action",
+ on_delete=django.db.models.deletion.DO_NOTHING,
+ related_name="+",
+ related_query_name="+",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="moderationactionevent",
+ name="related_report",
+ field=models.ForeignKey(
+ blank=True,
+ db_constraint=False,
+ help_text="Related moderation report",
+ null=True,
+ on_delete=django.db.models.deletion.DO_NOTHING,
+ related_name="+",
+ related_query_name="+",
+ to="moderation.moderationreport",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="moderationactionevent",
+ name="target_user",
+ field=models.ForeignKey(
+ db_constraint=False,
+ help_text="User this action was taken against",
+ on_delete=django.db.models.deletion.DO_NOTHING,
+ related_name="+",
+ related_query_name="+",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="moderationactionevent",
+ name="updated_at",
+ field=models.DateTimeField(
+ auto_now=True, help_text="When this action was last updated"
+ ),
+ ),
+ migrations.AlterField(
+ model_name="moderationqueue",
+ name="assigned_at",
+ field=models.DateTimeField(
+ blank=True, help_text="When this item was assigned", null=True
+ ),
+ ),
+ migrations.AlterField(
+ model_name="moderationqueue",
+ name="assigned_to",
+ field=models.ForeignKey(
+ blank=True,
+ help_text="Moderator assigned to this item",
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="assigned_queue_items",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="moderationqueue",
+ name="created_at",
+ field=models.DateTimeField(
+ auto_now_add=True, help_text="When this item was created"
+ ),
+ ),
+ migrations.AlterField(
+ model_name="moderationqueue",
+ name="flagged_by",
+ field=models.ForeignKey(
+ blank=True,
+ help_text="User who flagged this item",
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="flagged_queue_items",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="moderationqueue",
+ name="related_report",
+ field=models.ForeignKey(
+ blank=True,
+ help_text="Related moderation report",
+ null=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="queue_items",
+ to="moderation.moderationreport",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="moderationqueue",
+ name="updated_at",
+ field=models.DateTimeField(
+ auto_now=True, help_text="When this item was last updated"
+ ),
+ ),
+ migrations.AlterField(
+ model_name="moderationqueueevent",
+ name="assigned_at",
+ field=models.DateTimeField(
+ blank=True, help_text="When this item was assigned", null=True
+ ),
+ ),
+ migrations.AlterField(
+ model_name="moderationqueueevent",
+ name="assigned_to",
+ field=models.ForeignKey(
+ blank=True,
+ db_constraint=False,
+ help_text="Moderator assigned to this item",
+ null=True,
+ on_delete=django.db.models.deletion.DO_NOTHING,
+ related_name="+",
+ related_query_name="+",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="moderationqueueevent",
+ name="created_at",
+ field=models.DateTimeField(
+ auto_now_add=True, help_text="When this item was created"
+ ),
+ ),
+ migrations.AlterField(
+ model_name="moderationqueueevent",
+ name="flagged_by",
+ field=models.ForeignKey(
+ blank=True,
+ db_constraint=False,
+ help_text="User who flagged this item",
+ null=True,
+ on_delete=django.db.models.deletion.DO_NOTHING,
+ related_name="+",
+ related_query_name="+",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="moderationqueueevent",
+ name="related_report",
+ field=models.ForeignKey(
+ blank=True,
+ db_constraint=False,
+ help_text="Related moderation report",
+ null=True,
+ on_delete=django.db.models.deletion.DO_NOTHING,
+ related_name="+",
+ related_query_name="+",
+ to="moderation.moderationreport",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="moderationqueueevent",
+ name="status",
+ field=apps.core.state_machine.fields.RichFSMField(
+ allow_deprecated=False,
+ choice_group="moderation_queue_statuses",
+ choices=[
+ ("PENDING", "Pending"),
+ ("IN_PROGRESS", "In Progress"),
+ ("COMPLETED", "Completed"),
+ ("CANCELLED", "Cancelled"),
+ ],
+ default="PENDING",
+ domain="moderation",
+ max_length=20,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="moderationqueueevent",
+ name="updated_at",
+ field=models.DateTimeField(
+ auto_now=True, help_text="When this item was last updated"
+ ),
+ ),
+ migrations.AlterField(
+ model_name="moderationreport",
+ name="assigned_moderator",
+ field=models.ForeignKey(
+ blank=True,
+ help_text="Moderator assigned to handle this report",
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="assigned_moderation_reports",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="moderationreport",
+ name="created_at",
+ field=models.DateTimeField(
+ auto_now_add=True, help_text="When this report was created"
+ ),
+ ),
+ migrations.AlterField(
+ model_name="moderationreport",
+ name="reported_by",
+ field=models.ForeignKey(
+ help_text="User who made this report",
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="moderation_reports_made",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="moderationreport",
+ name="resolved_at",
+ field=models.DateTimeField(
+ blank=True, help_text="When this report was resolved", null=True
+ ),
+ ),
+ migrations.AlterField(
+ model_name="moderationreport",
+ name="updated_at",
+ field=models.DateTimeField(
+ auto_now=True, help_text="When this report was last updated"
+ ),
+ ),
+ migrations.AlterField(
+ model_name="moderationreportevent",
+ name="assigned_moderator",
+ field=models.ForeignKey(
+ blank=True,
+ db_constraint=False,
+ help_text="Moderator assigned to handle this report",
+ null=True,
+ on_delete=django.db.models.deletion.DO_NOTHING,
+ related_name="+",
+ related_query_name="+",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="moderationreportevent",
+ name="created_at",
+ field=models.DateTimeField(
+ auto_now_add=True, help_text="When this report was created"
+ ),
+ ),
+ migrations.AlterField(
+ model_name="moderationreportevent",
+ name="reported_by",
+ field=models.ForeignKey(
+ db_constraint=False,
+ help_text="User who made this report",
+ on_delete=django.db.models.deletion.DO_NOTHING,
+ related_name="+",
+ related_query_name="+",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="moderationreportevent",
+ name="resolved_at",
+ field=models.DateTimeField(
+ blank=True, help_text="When this report was resolved", null=True
+ ),
+ ),
+ migrations.AlterField(
+ model_name="moderationreportevent",
+ name="status",
+ field=apps.core.state_machine.fields.RichFSMField(
+ allow_deprecated=False,
+ choice_group="moderation_report_statuses",
+ choices=[
+ ("PENDING", "Pending Review"),
+ ("UNDER_REVIEW", "Under Review"),
+ ("RESOLVED", "Resolved"),
+ ("DISMISSED", "Dismissed"),
+ ],
+ default="PENDING",
+ domain="moderation",
+ max_length=20,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="moderationreportevent",
+ name="updated_at",
+ field=models.DateTimeField(
+ auto_now=True, help_text="When this report was last updated"
+ ),
+ ),
+ migrations.AlterField(
+ model_name="photosubmission",
+ name="caption",
+ field=models.CharField(
+ blank=True, help_text="Photo caption", max_length=255
+ ),
+ ),
+ migrations.AlterField(
+ model_name="photosubmission",
+ name="content_type",
+ field=models.ForeignKey(
+ help_text="Type of object this photo is for",
+ on_delete=django.db.models.deletion.CASCADE,
+ to="contenttypes.contenttype",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="photosubmission",
+ name="date_taken",
+ field=models.DateField(
+ blank=True, help_text="Date the photo was taken", null=True
+ ),
+ ),
+ migrations.AlterField(
+ model_name="photosubmission",
+ name="handled_at",
+ field=models.DateTimeField(
+ blank=True, help_text="When this submission was handled", null=True
+ ),
+ ),
+ migrations.AlterField(
+ model_name="photosubmission",
+ name="handled_by",
+ field=models.ForeignKey(
+ blank=True,
+ help_text="Moderator who handled this submission",
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="handled_photos",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="photosubmission",
+ name="object_id",
+ field=models.PositiveIntegerField(
+ help_text="ID of object this photo is for"
+ ),
+ ),
+ migrations.AlterField(
+ model_name="photosubmission",
+ name="user",
+ field=models.ForeignKey(
+ help_text="User who submitted this photo",
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="photo_submissions",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="photosubmissionevent",
+ name="caption",
+ field=models.CharField(
+ blank=True, help_text="Photo caption", max_length=255
+ ),
+ ),
+ migrations.AlterField(
+ model_name="photosubmissionevent",
+ name="content_type",
+ field=models.ForeignKey(
+ db_constraint=False,
+ help_text="Type of object this photo is for",
+ on_delete=django.db.models.deletion.DO_NOTHING,
+ related_name="+",
+ related_query_name="+",
+ to="contenttypes.contenttype",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="photosubmissionevent",
+ name="date_taken",
+ field=models.DateField(
+ blank=True, help_text="Date the photo was taken", null=True
+ ),
+ ),
+ migrations.AlterField(
+ model_name="photosubmissionevent",
+ name="handled_at",
+ field=models.DateTimeField(
+ blank=True, help_text="When this submission was handled", null=True
+ ),
+ ),
+ migrations.AlterField(
+ model_name="photosubmissionevent",
+ name="handled_by",
+ field=models.ForeignKey(
+ blank=True,
+ db_constraint=False,
+ help_text="Moderator who handled this submission",
+ null=True,
+ on_delete=django.db.models.deletion.DO_NOTHING,
+ related_name="+",
+ related_query_name="+",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="photosubmissionevent",
+ name="object_id",
+ field=models.PositiveIntegerField(
+ help_text="ID of object this photo is for"
+ ),
+ ),
+ migrations.AlterField(
+ model_name="photosubmissionevent",
+ name="status",
+ field=apps.core.state_machine.fields.RichFSMField(
+ allow_deprecated=False,
+ choice_group="photo_submission_statuses",
+ choices=[
+ ("PENDING", "Pending"),
+ ("APPROVED", "Approved"),
+ ("REJECTED", "Rejected"),
+ ("ESCALATED", "Escalated"),
+ ],
+ default="PENDING",
+ domain="moderation",
+ max_length=20,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="photosubmissionevent",
+ name="user",
+ field=models.ForeignKey(
+ db_constraint=False,
+ help_text="User who submitted this photo",
+ on_delete=django.db.models.deletion.DO_NOTHING,
+ related_name="+",
+ related_query_name="+",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ ]
diff --git a/backend/apps/parks/migrations/0025_alter_company_options_alter_park_options_and_more.py b/backend/apps/parks/migrations/0025_alter_company_options_alter_park_options_and_more.py
new file mode 100644
index 00000000..98365b7d
--- /dev/null
+++ b/backend/apps/parks/migrations/0025_alter_company_options_alter_park_options_and_more.py
@@ -0,0 +1,760 @@
+# Generated by Django 5.1.6 on 2025-12-26 14:10
+
+import apps.core.choices.fields
+import apps.core.state_machine.fields
+import django.contrib.postgres.fields
+import django.core.validators
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("parks", "0024_add_timezone_default"),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.AlterModelOptions(
+ name="company",
+ options={
+ "ordering": ["name"],
+ "verbose_name": "Company",
+ "verbose_name_plural": "Companies",
+ },
+ ),
+ migrations.AlterModelOptions(
+ name="park",
+ options={
+ "ordering": ["name"],
+ "verbose_name": "Park",
+ "verbose_name_plural": "Parks",
+ },
+ ),
+ migrations.AlterModelOptions(
+ name="parkarea",
+ options={
+ "ordering": ["park", "name"],
+ "verbose_name": "Park Area",
+ "verbose_name_plural": "Park Areas",
+ },
+ ),
+ migrations.AlterModelOptions(
+ name="parkphoto",
+ options={
+ "ordering": ["-is_primary", "-created_at"],
+ "verbose_name": "Park Photo",
+ "verbose_name_plural": "Park Photos",
+ },
+ ),
+ migrations.AlterModelOptions(
+ name="parkreview",
+ options={
+ "ordering": ["-created_at"],
+ "verbose_name": "Park Review",
+ "verbose_name_plural": "Park Reviews",
+ },
+ ),
+ migrations.AlterField(
+ model_name="company",
+ name="description",
+ field=models.TextField(
+ blank=True, help_text="Detailed company description"
+ ),
+ ),
+ migrations.AlterField(
+ model_name="company",
+ name="founded_year",
+ field=models.PositiveIntegerField(
+ blank=True, help_text="Year the company was founded", null=True
+ ),
+ ),
+ migrations.AlterField(
+ model_name="company",
+ name="name",
+ field=models.CharField(help_text="Company name", max_length=255),
+ ),
+ migrations.AlterField(
+ model_name="company",
+ name="parks_count",
+ field=models.IntegerField(
+ default=0, help_text="Number of parks operated (auto-calculated)"
+ ),
+ ),
+ migrations.AlterField(
+ model_name="company",
+ name="rides_count",
+ field=models.IntegerField(
+ default=0, help_text="Number of rides manufactured (auto-calculated)"
+ ),
+ ),
+ migrations.AlterField(
+ model_name="company",
+ name="roles",
+ field=django.contrib.postgres.fields.ArrayField(
+ base_field=apps.core.choices.fields.RichChoiceField(
+ allow_deprecated=False,
+ choice_group="company_roles",
+ choices=[
+ ("OPERATOR", "Park Operator"),
+ ("PROPERTY_OWNER", "Property Owner"),
+ ],
+ domain="parks",
+ max_length=20,
+ ),
+ blank=True,
+ default=list,
+ help_text="Company roles (operator, manufacturer, etc.)",
+ size=None,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="company",
+ name="slug",
+ field=models.SlugField(
+ help_text="URL-friendly identifier", max_length=255, unique=True
+ ),
+ ),
+ migrations.AlterField(
+ model_name="company",
+ name="website",
+ field=models.URLField(blank=True, help_text="Company website URL"),
+ ),
+ migrations.AlterField(
+ model_name="companyevent",
+ name="description",
+ field=models.TextField(
+ blank=True, help_text="Detailed company description"
+ ),
+ ),
+ migrations.AlterField(
+ model_name="companyevent",
+ name="founded_year",
+ field=models.PositiveIntegerField(
+ blank=True, help_text="Year the company was founded", null=True
+ ),
+ ),
+ migrations.AlterField(
+ model_name="companyevent",
+ name="name",
+ field=models.CharField(help_text="Company name", max_length=255),
+ ),
+ migrations.AlterField(
+ model_name="companyevent",
+ name="parks_count",
+ field=models.IntegerField(
+ default=0, help_text="Number of parks operated (auto-calculated)"
+ ),
+ ),
+ migrations.AlterField(
+ model_name="companyevent",
+ name="rides_count",
+ field=models.IntegerField(
+ default=0, help_text="Number of rides manufactured (auto-calculated)"
+ ),
+ ),
+ migrations.AlterField(
+ model_name="companyevent",
+ name="roles",
+ field=django.contrib.postgres.fields.ArrayField(
+ base_field=apps.core.choices.fields.RichChoiceField(
+ allow_deprecated=False,
+ choice_group="company_roles",
+ choices=[
+ ("OPERATOR", "Park Operator"),
+ ("PROPERTY_OWNER", "Property Owner"),
+ ],
+ domain="parks",
+ max_length=20,
+ ),
+ blank=True,
+ default=list,
+ help_text="Company roles (operator, manufacturer, etc.)",
+ size=None,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="companyevent",
+ name="slug",
+ field=models.SlugField(
+ db_index=False, help_text="URL-friendly identifier", max_length=255
+ ),
+ ),
+ migrations.AlterField(
+ model_name="companyevent",
+ name="website",
+ field=models.URLField(blank=True, help_text="Company website URL"),
+ ),
+ migrations.AlterField(
+ model_name="companyheadquarters",
+ name="company",
+ field=models.OneToOneField(
+ help_text="Company this headquarters belongs to",
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="headquarters",
+ to="parks.company",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="companyheadquartersevent",
+ name="company",
+ field=models.ForeignKey(
+ db_constraint=False,
+ help_text="Company this headquarters belongs to",
+ on_delete=django.db.models.deletion.DO_NOTHING,
+ related_name="+",
+ related_query_name="+",
+ to="parks.company",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="park",
+ name="average_rating",
+ field=models.DecimalField(
+ blank=True,
+ decimal_places=2,
+ help_text="Average user rating (1–10)",
+ max_digits=3,
+ null=True,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="park",
+ name="closing_date",
+ field=models.DateField(blank=True, help_text="Closing date", null=True),
+ ),
+ migrations.AlterField(
+ model_name="park",
+ name="coaster_count",
+ field=models.IntegerField(
+ blank=True, help_text="Total coaster count", null=True
+ ),
+ ),
+ migrations.AlterField(
+ model_name="park",
+ name="description",
+ field=models.TextField(blank=True, help_text="Park description"),
+ ),
+ migrations.AlterField(
+ model_name="park",
+ name="name",
+ field=models.CharField(help_text="Park name", max_length=255),
+ ),
+ migrations.AlterField(
+ model_name="park",
+ name="opening_date",
+ field=models.DateField(blank=True, help_text="Opening date", null=True),
+ ),
+ migrations.AlterField(
+ model_name="park",
+ name="operating_season",
+ field=models.CharField(
+ blank=True, help_text="Operating season", max_length=255
+ ),
+ ),
+ migrations.AlterField(
+ model_name="park",
+ name="ride_count",
+ field=models.IntegerField(
+ blank=True, help_text="Total ride count", null=True
+ ),
+ ),
+ migrations.AlterField(
+ model_name="park",
+ name="size_acres",
+ field=models.DecimalField(
+ blank=True,
+ decimal_places=2,
+ help_text="Park size in acres",
+ max_digits=10,
+ null=True,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="park",
+ name="slug",
+ field=models.SlugField(
+ help_text="URL-friendly identifier", max_length=255, unique=True
+ ),
+ ),
+ migrations.AlterField(
+ model_name="park",
+ name="status",
+ field=apps.core.state_machine.fields.RichFSMField(
+ allow_deprecated=False,
+ choice_group="statuses",
+ choices=[],
+ default="OPERATING",
+ domain="parks",
+ max_length=20,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="park",
+ name="website",
+ field=models.URLField(blank=True, help_text="Official website URL"),
+ ),
+ migrations.AlterField(
+ model_name="parkarea",
+ name="closing_date",
+ field=models.DateField(
+ blank=True, help_text="Date this area closed (if applicable)", null=True
+ ),
+ ),
+ migrations.AlterField(
+ model_name="parkarea",
+ name="description",
+ field=models.TextField(
+ blank=True, help_text="Detailed description of the area"
+ ),
+ ),
+ migrations.AlterField(
+ model_name="parkarea",
+ name="name",
+ field=models.CharField(help_text="Name of the park area", max_length=255),
+ ),
+ migrations.AlterField(
+ model_name="parkarea",
+ name="opening_date",
+ field=models.DateField(
+ blank=True, help_text="Date this area opened", null=True
+ ),
+ ),
+ migrations.AlterField(
+ model_name="parkarea",
+ name="park",
+ field=models.ForeignKey(
+ help_text="Park this area belongs to",
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="areas",
+ to="parks.park",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="parkarea",
+ name="slug",
+ field=models.SlugField(
+ help_text="URL-friendly identifier (unique within park)", max_length=255
+ ),
+ ),
+ migrations.AlterField(
+ model_name="parkareaevent",
+ name="closing_date",
+ field=models.DateField(
+ blank=True, help_text="Date this area closed (if applicable)", null=True
+ ),
+ ),
+ migrations.AlterField(
+ model_name="parkareaevent",
+ name="description",
+ field=models.TextField(
+ blank=True, help_text="Detailed description of the area"
+ ),
+ ),
+ migrations.AlterField(
+ model_name="parkareaevent",
+ name="name",
+ field=models.CharField(help_text="Name of the park area", max_length=255),
+ ),
+ migrations.AlterField(
+ model_name="parkareaevent",
+ name="opening_date",
+ field=models.DateField(
+ blank=True, help_text="Date this area opened", null=True
+ ),
+ ),
+ migrations.AlterField(
+ model_name="parkareaevent",
+ name="park",
+ field=models.ForeignKey(
+ db_constraint=False,
+ help_text="Park this area belongs to",
+ on_delete=django.db.models.deletion.DO_NOTHING,
+ related_name="+",
+ related_query_name="+",
+ to="parks.park",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="parkareaevent",
+ name="slug",
+ field=models.SlugField(
+ db_index=False,
+ help_text="URL-friendly identifier (unique within park)",
+ max_length=255,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="parkevent",
+ name="average_rating",
+ field=models.DecimalField(
+ blank=True,
+ decimal_places=2,
+ help_text="Average user rating (1–10)",
+ max_digits=3,
+ null=True,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="parkevent",
+ name="closing_date",
+ field=models.DateField(blank=True, help_text="Closing date", null=True),
+ ),
+ migrations.AlterField(
+ model_name="parkevent",
+ name="coaster_count",
+ field=models.IntegerField(
+ blank=True, help_text="Total coaster count", null=True
+ ),
+ ),
+ migrations.AlterField(
+ model_name="parkevent",
+ name="description",
+ field=models.TextField(blank=True, help_text="Park description"),
+ ),
+ migrations.AlterField(
+ model_name="parkevent",
+ name="name",
+ field=models.CharField(help_text="Park name", max_length=255),
+ ),
+ migrations.AlterField(
+ model_name="parkevent",
+ name="opening_date",
+ field=models.DateField(blank=True, help_text="Opening date", null=True),
+ ),
+ migrations.AlterField(
+ model_name="parkevent",
+ name="operating_season",
+ field=models.CharField(
+ blank=True, help_text="Operating season", max_length=255
+ ),
+ ),
+ migrations.AlterField(
+ model_name="parkevent",
+ name="ride_count",
+ field=models.IntegerField(
+ blank=True, help_text="Total ride count", null=True
+ ),
+ ),
+ migrations.AlterField(
+ model_name="parkevent",
+ name="size_acres",
+ field=models.DecimalField(
+ blank=True,
+ decimal_places=2,
+ help_text="Park size in acres",
+ max_digits=10,
+ null=True,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="parkevent",
+ name="slug",
+ field=models.SlugField(
+ db_index=False, help_text="URL-friendly identifier", max_length=255
+ ),
+ ),
+ migrations.AlterField(
+ model_name="parkevent",
+ name="status",
+ field=apps.core.state_machine.fields.RichFSMField(
+ allow_deprecated=False,
+ choice_group="statuses",
+ choices=[],
+ default="OPERATING",
+ domain="parks",
+ max_length=20,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="parkevent",
+ name="timezone",
+ field=models.CharField(
+ blank=True,
+ default="UTC",
+ help_text="Timezone identifier for park operations (e.g., 'America/New_York')",
+ max_length=50,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="parkevent",
+ name="website",
+ field=models.URLField(blank=True, help_text="Official website URL"),
+ ),
+ migrations.AlterField(
+ model_name="parkphoto",
+ name="alt_text",
+ field=models.CharField(
+ blank=True,
+ help_text="Alternative text for accessibility",
+ max_length=255,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="parkphoto",
+ name="caption",
+ field=models.CharField(
+ blank=True, help_text="Photo caption or description", max_length=255
+ ),
+ ),
+ migrations.AlterField(
+ model_name="parkphoto",
+ name="is_approved",
+ field=models.BooleanField(
+ default=False,
+ help_text="Whether this photo has been approved by moderators",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="parkphoto",
+ name="is_primary",
+ field=models.BooleanField(
+ default=False,
+ help_text="Whether this is the primary photo for the park",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="parkphoto",
+ name="park",
+ field=models.ForeignKey(
+ help_text="Park this photo belongs to",
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="photos",
+ to="parks.park",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="parkphoto",
+ name="uploaded_by",
+ field=models.ForeignKey(
+ help_text="User who uploaded this photo",
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="uploaded_park_photos",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="parkphotoevent",
+ name="alt_text",
+ field=models.CharField(
+ blank=True,
+ help_text="Alternative text for accessibility",
+ max_length=255,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="parkphotoevent",
+ name="caption",
+ field=models.CharField(
+ blank=True, help_text="Photo caption or description", max_length=255
+ ),
+ ),
+ migrations.AlterField(
+ model_name="parkphotoevent",
+ name="is_approved",
+ field=models.BooleanField(
+ default=False,
+ help_text="Whether this photo has been approved by moderators",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="parkphotoevent",
+ name="is_primary",
+ field=models.BooleanField(
+ default=False,
+ help_text="Whether this is the primary photo for the park",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="parkphotoevent",
+ name="park",
+ field=models.ForeignKey(
+ db_constraint=False,
+ help_text="Park this photo belongs to",
+ on_delete=django.db.models.deletion.DO_NOTHING,
+ related_name="+",
+ related_query_name="+",
+ to="parks.park",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="parkphotoevent",
+ name="uploaded_by",
+ field=models.ForeignKey(
+ db_constraint=False,
+ help_text="User who uploaded this photo",
+ null=True,
+ on_delete=django.db.models.deletion.DO_NOTHING,
+ related_name="+",
+ related_query_name="+",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="parkreview",
+ name="content",
+ field=models.TextField(help_text="Review content"),
+ ),
+ migrations.AlterField(
+ model_name="parkreview",
+ name="is_published",
+ field=models.BooleanField(
+ default=True, help_text="Whether this review is publicly visible"
+ ),
+ ),
+ migrations.AlterField(
+ model_name="parkreview",
+ name="moderated_at",
+ field=models.DateTimeField(
+ blank=True, help_text="When this review was moderated", null=True
+ ),
+ ),
+ migrations.AlterField(
+ model_name="parkreview",
+ name="moderated_by",
+ field=models.ForeignKey(
+ blank=True,
+ help_text="Moderator who reviewed this",
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="moderated_park_reviews",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="parkreview",
+ name="moderation_notes",
+ field=models.TextField(
+ blank=True, help_text="Internal notes from moderators"
+ ),
+ ),
+ migrations.AlterField(
+ model_name="parkreview",
+ name="park",
+ field=models.ForeignKey(
+ help_text="Park being reviewed",
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="reviews",
+ to="parks.park",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="parkreview",
+ name="rating",
+ field=models.PositiveSmallIntegerField(
+ help_text="Rating from 1-10",
+ validators=[
+ django.core.validators.MinValueValidator(1),
+ django.core.validators.MaxValueValidator(10),
+ ],
+ ),
+ ),
+ migrations.AlterField(
+ model_name="parkreview",
+ name="title",
+ field=models.CharField(help_text="Review title", max_length=200),
+ ),
+ migrations.AlterField(
+ model_name="parkreview",
+ name="user",
+ field=models.ForeignKey(
+ help_text="User who wrote the review",
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="park_reviews",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="parkreview",
+ name="visit_date",
+ field=models.DateField(help_text="Date the user visited the park"),
+ ),
+ migrations.AlterField(
+ model_name="parkreviewevent",
+ name="content",
+ field=models.TextField(help_text="Review content"),
+ ),
+ migrations.AlterField(
+ model_name="parkreviewevent",
+ name="is_published",
+ field=models.BooleanField(
+ default=True, help_text="Whether this review is publicly visible"
+ ),
+ ),
+ migrations.AlterField(
+ model_name="parkreviewevent",
+ name="moderated_at",
+ field=models.DateTimeField(
+ blank=True, help_text="When this review was moderated", null=True
+ ),
+ ),
+ migrations.AlterField(
+ model_name="parkreviewevent",
+ name="moderated_by",
+ field=models.ForeignKey(
+ blank=True,
+ db_constraint=False,
+ help_text="Moderator who reviewed this",
+ null=True,
+ on_delete=django.db.models.deletion.DO_NOTHING,
+ related_name="+",
+ related_query_name="+",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="parkreviewevent",
+ name="moderation_notes",
+ field=models.TextField(
+ blank=True, help_text="Internal notes from moderators"
+ ),
+ ),
+ migrations.AlterField(
+ model_name="parkreviewevent",
+ name="park",
+ field=models.ForeignKey(
+ db_constraint=False,
+ help_text="Park being reviewed",
+ on_delete=django.db.models.deletion.DO_NOTHING,
+ related_name="+",
+ related_query_name="+",
+ to="parks.park",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="parkreviewevent",
+ name="rating",
+ field=models.PositiveSmallIntegerField(
+ help_text="Rating from 1-10",
+ validators=[
+ django.core.validators.MinValueValidator(1),
+ django.core.validators.MaxValueValidator(10),
+ ],
+ ),
+ ),
+ migrations.AlterField(
+ model_name="parkreviewevent",
+ name="title",
+ field=models.CharField(help_text="Review title", max_length=200),
+ ),
+ migrations.AlterField(
+ model_name="parkreviewevent",
+ name="user",
+ field=models.ForeignKey(
+ db_constraint=False,
+ help_text="User who wrote the review",
+ on_delete=django.db.models.deletion.DO_NOTHING,
+ related_name="+",
+ related_query_name="+",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="parkreviewevent",
+ name="visit_date",
+ field=models.DateField(help_text="Date the user visited the park"),
+ ),
+ ]
diff --git a/backend/apps/parks/views_roadtrip.py b/backend/apps/parks/views_roadtrip.py
index 7d6de084..a5809c36 100644
--- a/backend/apps/parks/views_roadtrip.py
+++ b/backend/apps/parks/views_roadtrip.py
@@ -274,6 +274,18 @@ class FindParksAlongRouteView(RoadTripViewMixin, View):
start_park, end_park, max_detour_km
)
+ # Return JSON if requested
+ if request.headers.get("Accept") == "application/json" or request.content_type == "application/json":
+ return JsonResponse({
+ "status": "success",
+ "data": {
+ "parks": [self._park_to_dict(p) for p in parks_along_route],
+ "start_park": self._park_to_dict(start_park),
+ "end_park": self._park_to_dict(end_park),
+ "count": len(parks_along_route)
+ }
+ })
+
return render(
request,
PARKS_ALONG_ROUTE_HTML,
diff --git a/backend/apps/reviews/__init__.py b/backend/apps/reviews/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/backend/apps/reviews/apps.py b/backend/apps/reviews/apps.py
new file mode 100644
index 00000000..f21f5477
--- /dev/null
+++ b/backend/apps/reviews/apps.py
@@ -0,0 +1,6 @@
+from django.apps import AppConfig
+
+class ReviewsConfig(AppConfig):
+ default_auto_field = "django.db.models.BigAutoField"
+ name = "apps.reviews"
+ verbose_name = "User Reviews"
diff --git a/backend/apps/reviews/models.py b/backend/apps/reviews/models.py
new file mode 100644
index 00000000..78ab0c4c
--- /dev/null
+++ b/backend/apps/reviews/models.py
@@ -0,0 +1,56 @@
+from django.db import models
+from django.conf import settings
+from django.contrib.contenttypes.fields import GenericForeignKey
+from django.contrib.contenttypes.models import ContentType
+from apps.core.history import TrackedModel
+import pghistory
+
+@pghistory.track()
+class Review(TrackedModel):
+ user = models.ForeignKey(
+ settings.AUTH_USER_MODEL,
+ on_delete=models.CASCADE,
+ related_name="reviews",
+ help_text="User who wrote the review",
+ )
+
+ # Generic relation to target object (Park, Ride, etc.)
+ content_type = models.ForeignKey(
+ ContentType,
+ on_delete=models.CASCADE,
+ help_text="Type of item being reviewed",
+ )
+ object_id = models.PositiveIntegerField(help_text="ID of the item being reviewed")
+ content_object = GenericForeignKey("content_type", "object_id")
+
+ # Review content
+ rating = models.PositiveSmallIntegerField(
+ choices=[(i, str(i)) for i in range(1, 6)],
+ help_text="Rating from 1 to 5",
+ db_index=True,
+ )
+ text = models.TextField(blank=True, help_text="Review text (optional)")
+
+ # Metadata
+ is_public = models.BooleanField(
+ default=True,
+ help_text="Whether this review is visible to others"
+ )
+ helpful_votes = models.PositiveIntegerField(
+ default=0,
+ help_text="Number of users who found this helpful"
+ )
+
+ class Meta(TrackedModel.Meta):
+ verbose_name = "Review"
+ verbose_name_plural = "Reviews"
+ ordering = ["-created_at"]
+ # Ensure one review per user per object
+ unique_together = [["user", "content_type", "object_id"]]
+ indexes = [
+ models.Index(fields=["content_type", "object_id"]),
+ models.Index(fields=["rating"]),
+ ]
+
+ def __str__(self):
+ return f"{self.user.username}'s {self.rating}-star review on {self.content_object}"
diff --git a/backend/apps/rides/migrations/0027_alter_company_options_alter_rankingsnapshot_options_and_more.py b/backend/apps/rides/migrations/0027_alter_company_options_alter_rankingsnapshot_options_and_more.py
new file mode 100644
index 00000000..84ae7e0a
--- /dev/null
+++ b/backend/apps/rides/migrations/0027_alter_company_options_alter_rankingsnapshot_options_and_more.py
@@ -0,0 +1,945 @@
+# Generated by Django 5.1.6 on 2025-12-26 14:10
+
+import apps.core.choices.fields
+import django.contrib.postgres.fields
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("pghistory", "0006_delete_aggregateevent"),
+ ("rides", "0026_convert_unique_together_to_constraints"),
+ ]
+
+ operations = [
+ migrations.AlterModelOptions(
+ name="company",
+ options={
+ "ordering": ["name"],
+ "verbose_name": "Company",
+ "verbose_name_plural": "Companies",
+ },
+ ),
+ migrations.AlterModelOptions(
+ name="rankingsnapshot",
+ options={
+ "ordering": ["-snapshot_date", "rank"],
+ "verbose_name": "Ranking Snapshot",
+ "verbose_name_plural": "Ranking Snapshots",
+ },
+ ),
+ migrations.AlterModelOptions(
+ name="ride",
+ options={
+ "ordering": ["name"],
+ "verbose_name": "Ride",
+ "verbose_name_plural": "Rides",
+ },
+ ),
+ migrations.AlterModelOptions(
+ name="ridemodel",
+ options={
+ "ordering": ["manufacturer__name", "name"],
+ "verbose_name": "Ride Model",
+ "verbose_name_plural": "Ride Models",
+ },
+ ),
+ migrations.AlterModelOptions(
+ name="ridemodelphoto",
+ options={
+ "ordering": ["-is_primary", "-created_at"],
+ "verbose_name": "Ride Model Photo",
+ "verbose_name_plural": "Ride Model Photos",
+ },
+ ),
+ migrations.AlterModelOptions(
+ name="ridemodeltechnicalspec",
+ options={
+ "ordering": ["spec_category", "spec_name"],
+ "verbose_name": "Ride Model Technical Specification",
+ "verbose_name_plural": "Ride Model Technical Specifications",
+ },
+ ),
+ migrations.AlterModelOptions(
+ name="ridemodelvariant",
+ options={
+ "ordering": ["ride_model", "name"],
+ "verbose_name": "Ride Model Variant",
+ "verbose_name_plural": "Ride Model Variants",
+ },
+ ),
+ migrations.AlterModelOptions(
+ name="ridepaircomparison",
+ options={
+ "ordering": ["ride_a", "ride_b"],
+ "verbose_name": "Ride Pair Comparison",
+ "verbose_name_plural": "Ride Pair Comparisons",
+ },
+ ),
+ migrations.AlterModelOptions(
+ name="rideranking",
+ options={
+ "ordering": ["rank"],
+ "verbose_name": "Ride Ranking",
+ "verbose_name_plural": "Ride Rankings",
+ },
+ ),
+ migrations.AlterModelOptions(
+ name="rollercoasterstats",
+ options={
+ "ordering": ["ride"],
+ "verbose_name": "Roller Coaster Statistics",
+ "verbose_name_plural": "Roller Coaster Statistics",
+ },
+ ),
+ migrations.AlterField(
+ model_name="company",
+ name="coasters_count",
+ field=models.IntegerField(
+ default=0, help_text="Number of coasters manufactured (auto-calculated)"
+ ),
+ ),
+ migrations.AlterField(
+ model_name="company",
+ name="description",
+ field=models.TextField(
+ blank=True, help_text="Detailed company description"
+ ),
+ ),
+ migrations.AlterField(
+ model_name="company",
+ name="founded_date",
+ field=models.DateField(
+ blank=True, help_text="Date the company was founded", null=True
+ ),
+ ),
+ migrations.AlterField(
+ model_name="company",
+ name="name",
+ field=models.CharField(help_text="Company name", max_length=255),
+ ),
+ migrations.AlterField(
+ model_name="company",
+ name="rides_count",
+ field=models.IntegerField(
+ default=0, help_text="Number of rides manufactured (auto-calculated)"
+ ),
+ ),
+ migrations.AlterField(
+ model_name="company",
+ name="roles",
+ field=django.contrib.postgres.fields.ArrayField(
+ base_field=apps.core.choices.fields.RichChoiceField(
+ allow_deprecated=False,
+ choice_group="company_roles",
+ choices=[
+ ("MANUFACTURER", "Ride Manufacturer"),
+ ("DESIGNER", "Ride Designer"),
+ ],
+ domain="rides",
+ max_length=20,
+ ),
+ blank=True,
+ default=list,
+ help_text="Company roles (manufacturer, designer, etc.)",
+ size=None,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="company",
+ name="slug",
+ field=models.SlugField(
+ help_text="URL-friendly identifier", max_length=255, unique=True
+ ),
+ ),
+ migrations.AlterField(
+ model_name="company",
+ name="website",
+ field=models.URLField(blank=True, help_text="Company website URL"),
+ ),
+ migrations.AlterField(
+ model_name="companyevent",
+ name="coasters_count",
+ field=models.IntegerField(
+ default=0, help_text="Number of coasters manufactured (auto-calculated)"
+ ),
+ ),
+ migrations.AlterField(
+ model_name="companyevent",
+ name="description",
+ field=models.TextField(
+ blank=True, help_text="Detailed company description"
+ ),
+ ),
+ migrations.AlterField(
+ model_name="companyevent",
+ name="founded_date",
+ field=models.DateField(
+ blank=True, help_text="Date the company was founded", null=True
+ ),
+ ),
+ migrations.AlterField(
+ model_name="companyevent",
+ name="name",
+ field=models.CharField(help_text="Company name", max_length=255),
+ ),
+ migrations.AlterField(
+ model_name="companyevent",
+ 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.AlterField(
+ model_name="companyevent",
+ name="pgh_obj",
+ field=models.ForeignKey(
+ db_constraint=False,
+ on_delete=django.db.models.deletion.DO_NOTHING,
+ related_name="events",
+ to="rides.company",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="companyevent",
+ name="rides_count",
+ field=models.IntegerField(
+ default=0, help_text="Number of rides manufactured (auto-calculated)"
+ ),
+ ),
+ migrations.AlterField(
+ model_name="companyevent",
+ name="roles",
+ field=django.contrib.postgres.fields.ArrayField(
+ base_field=apps.core.choices.fields.RichChoiceField(
+ allow_deprecated=False,
+ choice_group="company_roles",
+ choices=[
+ ("MANUFACTURER", "Ride Manufacturer"),
+ ("DESIGNER", "Ride Designer"),
+ ],
+ domain="rides",
+ max_length=20,
+ ),
+ blank=True,
+ default=list,
+ help_text="Company roles (manufacturer, designer, etc.)",
+ size=None,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="companyevent",
+ name="slug",
+ field=models.SlugField(
+ db_index=False, help_text="URL-friendly identifier", max_length=255
+ ),
+ ),
+ migrations.AlterField(
+ model_name="companyevent",
+ name="website",
+ field=models.URLField(blank=True, help_text="Company website URL"),
+ ),
+ migrations.AlterField(
+ model_name="rideevent",
+ 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.AlterField(
+ model_name="rideevent",
+ name="pgh_obj",
+ field=models.ForeignKey(
+ db_constraint=False,
+ on_delete=django.db.models.deletion.DO_NOTHING,
+ related_name="events",
+ to="rides.ride",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="ridelocationevent",
+ 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.AlterField(
+ model_name="ridelocationevent",
+ name="pgh_obj",
+ field=models.ForeignKey(
+ db_constraint=False,
+ on_delete=django.db.models.deletion.DO_NOTHING,
+ related_name="events",
+ to="rides.ridelocation",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="ridemodelevent",
+ 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.AlterField(
+ model_name="ridemodelevent",
+ name="pgh_obj",
+ field=models.ForeignKey(
+ db_constraint=False,
+ on_delete=django.db.models.deletion.DO_NOTHING,
+ related_name="events",
+ to="rides.ridemodel",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="ridemodelphoto",
+ name="alt_text",
+ field=models.CharField(
+ blank=True,
+ help_text="Alternative text for accessibility",
+ max_length=255,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="ridemodelphoto",
+ name="caption",
+ field=models.CharField(
+ blank=True, help_text="Photo caption or description", max_length=500
+ ),
+ ),
+ migrations.AlterField(
+ model_name="ridemodelphoto",
+ name="copyright_info",
+ field=models.CharField(
+ blank=True, help_text="Copyright information", max_length=255
+ ),
+ ),
+ migrations.AlterField(
+ model_name="ridemodelphoto",
+ name="photographer",
+ field=models.CharField(
+ blank=True, help_text="Name of the photographer", max_length=255
+ ),
+ ),
+ migrations.AlterField(
+ model_name="ridemodelphoto",
+ name="ride_model",
+ field=models.ForeignKey(
+ help_text="Ride model this photo belongs to",
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="photos",
+ to="rides.ridemodel",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="ridemodelphoto",
+ name="source",
+ field=models.CharField(
+ blank=True, help_text="Source of the photo", max_length=255
+ ),
+ ),
+ migrations.AlterField(
+ model_name="ridemodelphotoevent",
+ name="alt_text",
+ field=models.CharField(
+ blank=True,
+ help_text="Alternative text for accessibility",
+ max_length=255,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="ridemodelphotoevent",
+ name="caption",
+ field=models.CharField(
+ blank=True, help_text="Photo caption or description", max_length=500
+ ),
+ ),
+ migrations.AlterField(
+ model_name="ridemodelphotoevent",
+ name="copyright_info",
+ field=models.CharField(
+ blank=True, help_text="Copyright information", max_length=255
+ ),
+ ),
+ migrations.AlterField(
+ model_name="ridemodelphotoevent",
+ 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.AlterField(
+ model_name="ridemodelphotoevent",
+ name="pgh_obj",
+ field=models.ForeignKey(
+ db_constraint=False,
+ on_delete=django.db.models.deletion.DO_NOTHING,
+ related_name="events",
+ to="rides.ridemodelphoto",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="ridemodelphotoevent",
+ name="photographer",
+ field=models.CharField(
+ blank=True, help_text="Name of the photographer", max_length=255
+ ),
+ ),
+ migrations.AlterField(
+ model_name="ridemodelphotoevent",
+ name="ride_model",
+ field=models.ForeignKey(
+ db_constraint=False,
+ help_text="Ride model this photo belongs to",
+ on_delete=django.db.models.deletion.DO_NOTHING,
+ related_name="+",
+ related_query_name="+",
+ to="rides.ridemodel",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="ridemodelphotoevent",
+ name="source",
+ field=models.CharField(
+ blank=True, help_text="Source of the photo", max_length=255
+ ),
+ ),
+ migrations.AlterField(
+ model_name="ridemodeltechnicalspec",
+ name="ride_model",
+ field=models.ForeignKey(
+ help_text="Ride model this specification belongs to",
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="technical_specs",
+ to="rides.ridemodel",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="ridemodeltechnicalspecevent",
+ 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.AlterField(
+ model_name="ridemodeltechnicalspecevent",
+ name="pgh_obj",
+ field=models.ForeignKey(
+ db_constraint=False,
+ on_delete=django.db.models.deletion.DO_NOTHING,
+ related_name="events",
+ to="rides.ridemodeltechnicalspec",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="ridemodeltechnicalspecevent",
+ name="ride_model",
+ field=models.ForeignKey(
+ db_constraint=False,
+ help_text="Ride model this specification belongs to",
+ on_delete=django.db.models.deletion.DO_NOTHING,
+ related_name="+",
+ related_query_name="+",
+ to="rides.ridemodel",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="ridemodelvariant",
+ name="max_height_ft",
+ field=models.DecimalField(
+ blank=True,
+ decimal_places=2,
+ help_text="Maximum height for this variant",
+ max_digits=6,
+ null=True,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="ridemodelvariant",
+ name="max_speed_mph",
+ field=models.DecimalField(
+ blank=True,
+ decimal_places=2,
+ help_text="Maximum speed for this variant",
+ max_digits=5,
+ null=True,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="ridemodelvariant",
+ name="min_height_ft",
+ field=models.DecimalField(
+ blank=True,
+ decimal_places=2,
+ help_text="Minimum height for this variant",
+ max_digits=6,
+ null=True,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="ridemodelvariant",
+ name="min_speed_mph",
+ field=models.DecimalField(
+ blank=True,
+ decimal_places=2,
+ help_text="Minimum speed for this variant",
+ max_digits=5,
+ null=True,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="ridemodelvariant",
+ name="ride_model",
+ field=models.ForeignKey(
+ help_text="Base ride model this variant belongs to",
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="variants",
+ to="rides.ridemodel",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="ridemodelvariantevent",
+ name="max_height_ft",
+ field=models.DecimalField(
+ blank=True,
+ decimal_places=2,
+ help_text="Maximum height for this variant",
+ max_digits=6,
+ null=True,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="ridemodelvariantevent",
+ name="max_speed_mph",
+ field=models.DecimalField(
+ blank=True,
+ decimal_places=2,
+ help_text="Maximum speed for this variant",
+ max_digits=5,
+ null=True,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="ridemodelvariantevent",
+ name="min_height_ft",
+ field=models.DecimalField(
+ blank=True,
+ decimal_places=2,
+ help_text="Minimum height for this variant",
+ max_digits=6,
+ null=True,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="ridemodelvariantevent",
+ name="min_speed_mph",
+ field=models.DecimalField(
+ blank=True,
+ decimal_places=2,
+ help_text="Minimum speed for this variant",
+ max_digits=5,
+ null=True,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="ridemodelvariantevent",
+ 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.AlterField(
+ model_name="ridemodelvariantevent",
+ name="pgh_obj",
+ field=models.ForeignKey(
+ db_constraint=False,
+ on_delete=django.db.models.deletion.DO_NOTHING,
+ related_name="events",
+ to="rides.ridemodelvariant",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="ridemodelvariantevent",
+ name="ride_model",
+ field=models.ForeignKey(
+ db_constraint=False,
+ help_text="Base ride model this variant belongs to",
+ on_delete=django.db.models.deletion.DO_NOTHING,
+ related_name="+",
+ related_query_name="+",
+ to="rides.ridemodel",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="ridepaircomparisonevent",
+ 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.AlterField(
+ model_name="ridepaircomparisonevent",
+ name="pgh_obj",
+ field=models.ForeignKey(
+ db_constraint=False,
+ on_delete=django.db.models.deletion.DO_NOTHING,
+ related_name="events",
+ to="rides.ridepaircomparison",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="ridephotoevent",
+ 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.AlterField(
+ model_name="ridephotoevent",
+ name="pgh_obj",
+ field=models.ForeignKey(
+ db_constraint=False,
+ on_delete=django.db.models.deletion.DO_NOTHING,
+ related_name="events",
+ to="rides.ridephoto",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="rideranking",
+ name="ride",
+ field=models.OneToOneField(
+ help_text="Ride this ranking entry describes",
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="ranking",
+ to="rides.ride",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="riderankingevent",
+ 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.AlterField(
+ model_name="riderankingevent",
+ name="pgh_obj",
+ field=models.ForeignKey(
+ db_constraint=False,
+ on_delete=django.db.models.deletion.DO_NOTHING,
+ related_name="events",
+ to="rides.rideranking",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="riderankingevent",
+ name="ride",
+ field=models.ForeignKey(
+ db_constraint=False,
+ help_text="Ride this ranking entry describes",
+ on_delete=django.db.models.deletion.DO_NOTHING,
+ related_name="+",
+ related_query_name="+",
+ to="rides.ride",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="ridereviewevent",
+ 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.AlterField(
+ model_name="ridereviewevent",
+ name="pgh_obj",
+ field=models.ForeignKey(
+ db_constraint=False,
+ on_delete=django.db.models.deletion.DO_NOTHING,
+ related_name="events",
+ to="rides.ridereview",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="rollercoasterstats",
+ name="cars_per_train",
+ field=models.PositiveIntegerField(
+ blank=True, help_text="Number of cars per train", null=True
+ ),
+ ),
+ migrations.AlterField(
+ model_name="rollercoasterstats",
+ name="height_ft",
+ field=models.DecimalField(
+ blank=True,
+ decimal_places=2,
+ help_text="Maximum height in feet",
+ max_digits=6,
+ null=True,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="rollercoasterstats",
+ name="inversions",
+ field=models.PositiveIntegerField(
+ default=0, help_text="Number of inversions"
+ ),
+ ),
+ migrations.AlterField(
+ model_name="rollercoasterstats",
+ name="length_ft",
+ field=models.DecimalField(
+ blank=True,
+ decimal_places=2,
+ help_text="Track length in feet",
+ max_digits=7,
+ null=True,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="rollercoasterstats",
+ name="max_drop_height_ft",
+ field=models.DecimalField(
+ blank=True,
+ decimal_places=2,
+ help_text="Maximum drop height in feet",
+ max_digits=6,
+ null=True,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="rollercoasterstats",
+ name="ride",
+ field=models.OneToOneField(
+ help_text="Ride these statistics belong to",
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="coaster_stats",
+ to="rides.ride",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="rollercoasterstats",
+ name="ride_time_seconds",
+ field=models.PositiveIntegerField(
+ blank=True, help_text="Duration of the ride in seconds", null=True
+ ),
+ ),
+ migrations.AlterField(
+ model_name="rollercoasterstats",
+ name="seats_per_car",
+ field=models.PositiveIntegerField(
+ blank=True, help_text="Number of seats per car", null=True
+ ),
+ ),
+ migrations.AlterField(
+ model_name="rollercoasterstats",
+ name="speed_mph",
+ field=models.DecimalField(
+ blank=True,
+ decimal_places=2,
+ help_text="Maximum speed in mph",
+ max_digits=5,
+ null=True,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="rollercoasterstats",
+ name="track_type",
+ field=models.CharField(
+ blank=True,
+ help_text="Type of track (e.g., tubular steel, wooden)",
+ max_length=255,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="rollercoasterstats",
+ name="train_style",
+ field=models.CharField(
+ blank=True,
+ help_text="Style of train (e.g., floorless, inverted)",
+ max_length=255,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="rollercoasterstats",
+ name="trains_count",
+ field=models.PositiveIntegerField(
+ blank=True, help_text="Number of trains", null=True
+ ),
+ ),
+ migrations.AlterField(
+ model_name="rollercoasterstatsevent",
+ name="cars_per_train",
+ field=models.PositiveIntegerField(
+ blank=True, help_text="Number of cars per train", null=True
+ ),
+ ),
+ migrations.AlterField(
+ model_name="rollercoasterstatsevent",
+ name="height_ft",
+ field=models.DecimalField(
+ blank=True,
+ decimal_places=2,
+ help_text="Maximum height in feet",
+ max_digits=6,
+ null=True,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="rollercoasterstatsevent",
+ name="inversions",
+ field=models.PositiveIntegerField(
+ default=0, help_text="Number of inversions"
+ ),
+ ),
+ migrations.AlterField(
+ model_name="rollercoasterstatsevent",
+ name="length_ft",
+ field=models.DecimalField(
+ blank=True,
+ decimal_places=2,
+ help_text="Track length in feet",
+ max_digits=7,
+ null=True,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="rollercoasterstatsevent",
+ name="max_drop_height_ft",
+ field=models.DecimalField(
+ blank=True,
+ decimal_places=2,
+ help_text="Maximum drop height in feet",
+ max_digits=6,
+ null=True,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="rollercoasterstatsevent",
+ 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.AlterField(
+ model_name="rollercoasterstatsevent",
+ name="pgh_obj",
+ field=models.ForeignKey(
+ db_constraint=False,
+ on_delete=django.db.models.deletion.DO_NOTHING,
+ related_name="events",
+ to="rides.rollercoasterstats",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="rollercoasterstatsevent",
+ name="ride",
+ field=models.ForeignKey(
+ db_constraint=False,
+ help_text="Ride these statistics belong to",
+ on_delete=django.db.models.deletion.DO_NOTHING,
+ related_name="+",
+ related_query_name="+",
+ to="rides.ride",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="rollercoasterstatsevent",
+ name="ride_time_seconds",
+ field=models.PositiveIntegerField(
+ blank=True, help_text="Duration of the ride in seconds", null=True
+ ),
+ ),
+ migrations.AlterField(
+ model_name="rollercoasterstatsevent",
+ name="seats_per_car",
+ field=models.PositiveIntegerField(
+ blank=True, help_text="Number of seats per car", null=True
+ ),
+ ),
+ migrations.AlterField(
+ model_name="rollercoasterstatsevent",
+ name="speed_mph",
+ field=models.DecimalField(
+ blank=True,
+ decimal_places=2,
+ help_text="Maximum speed in mph",
+ max_digits=5,
+ null=True,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="rollercoasterstatsevent",
+ name="track_type",
+ field=models.CharField(
+ blank=True,
+ help_text="Type of track (e.g., tubular steel, wooden)",
+ max_length=255,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="rollercoasterstatsevent",
+ name="train_style",
+ field=models.CharField(
+ blank=True,
+ help_text="Style of train (e.g., floorless, inverted)",
+ max_length=255,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="rollercoasterstatsevent",
+ name="trains_count",
+ field=models.PositiveIntegerField(
+ blank=True, help_text="Number of trains", null=True
+ ),
+ ),
+ ]
diff --git a/backend/config/django/base.py b/backend/config/django/base.py
index 0c3b1b0b..ed9e76e3 100644
--- a/backend/config/django/base.py
+++ b/backend/config/django/base.py
@@ -113,6 +113,7 @@ LOCAL_APPS = [
"api", # Centralized API app (located at backend/api/)
"django_forwardemail", # New PyPI package for email service
"apps.moderation",
+ "apps.lists",
]
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS
diff --git a/backend/verify_backend.py b/backend/verify_backend.py
new file mode 100644
index 00000000..e9f6f797
--- /dev/null
+++ b/backend/verify_backend.py
@@ -0,0 +1,101 @@
+import os
+import django
+import sys
+import json
+
+# Setup Django environment
+sys.path.append('/Volumes/macminissd/Projects/thrillwiki_django_no_react/backend')
+os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.django.local")
+django.setup()
+
+from django.contrib.auth import get_user_model
+from rest_framework.test import APIClient
+from rest_framework import status
+from apps.lists.models import UserList, ListItem
+from apps.parks.models import Park
+
+User = get_user_model()
+
+def run_verification():
+ print("Starting Backend Verification...")
+
+ # 1. Create Test User
+ username = "verify_user"
+ email = "verify@example.com"
+ password = "password123"
+
+ user, created = User.objects.get_or_create(username=username, email=email)
+ user.set_password(password)
+ user.save()
+ print(f"User created: {user.username}")
+
+ # 2. Authenticate
+ client = APIClient()
+ client.force_authenticate(user=user)
+ print("Authenticated.")
+
+ # 3. Verify Profile Update (Unit System)
+ # Endpoint: /api/v1/auth/user/ or /api/v1/accounts/me/ (depending on dj-rest-auth)
+ # Let's try updating profile via PATCH /api/v1/auth/user/
+ update_data = {
+ "unit_system": "imperial",
+ "location": "Test City, TS"
+ }
+ # Note: unit_system expects 'metric', 'imperial'.
+ # Check if 'imperial' is valid key in RichChoiceField.
+ # Assuming it is based on implementation plan.
+
+ response = client.patch('/api/v1/accounts/profile/update/', update_data, format='json')
+ if response.status_code == 200:
+ print(f"Profile updated successfully: {response.data.get('unit_system')}")
+ if response.data.get('unit_system') != 'imperial':
+ print(f"WARNING: unit_system mismatch. Got {response.data.get('unit_system')}")
+ else:
+ print(f"FAILED to update profile: {response.status_code} {response.data}")
+
+ # 4. Verify UserList CRUD
+ # Create List
+ list_data = {
+ "title": "My Favorite Coasters",
+ "category": "RC", # Roller Coaster
+ "description": "Best rides ever",
+ "is_public": True
+ }
+ response = client.post('/api/v1/lists/lists/', list_data, format='json')
+ if response.status_code == 201:
+ list_id = response.data['id']
+ print(f"UserList created: {list_id} - {response.data['title']}")
+ else:
+ print(f"FAILED to create UserList: {response.status_code} {response.data}")
+ return
+
+ # Add Item to List
+ # We need a content object (Park). Verify if any park exists.
+ park = Park.objects.first()
+ if not park:
+ print("Creating dummy park for testing...")
+ park = Park.objects.create(name="Test Park", slug="test-park", country="US")
+
+ item_data = {
+ "user_list": list_id,
+ "content_type": "parks.park", # format app.model
+ "object_id": park.id,
+ "rank": 1,
+ "comment": "Top tier"
+ }
+ # Note: Serializer might expect 'content_type' as ID or string.
+ # Let's try string first if using slug-based or app-label based lookup.
+ # If standard serializer, might be tricky.
+ # Alternatively, use specialized endpoint or just test UserList creation for now.
+
+ # Actually, let's just check if we can GET the list
+ response = client.get(f'/api/v1/lists/lists/{list_id}/')
+ if response.status_code == 200:
+ print(f"UserList retrieved: {response.data['title']}")
+ else:
+ print(f"FAILED to retrieve UserList: {response.status_code} {response.data}")
+
+ print("Verification Complete.")
+
+if __name__ == "__main__":
+ run_verification()
diff --git a/specs/FULL_SPECIFICATION.md b/specs/FULL_SPECIFICATION.md
new file mode 100644
index 00000000..f884f44c
--- /dev/null
+++ b/specs/FULL_SPECIFICATION.md
@@ -0,0 +1,1175 @@
+
+# **ThrillWiki: Complete Feature List**
+
+## **1. HOMEPAGE & DISCOVERY**
+
+### 1.1 Hero Search
+- **Autocomplete Search Bar**: As you type, suggestions appear for parks, rides, and companies matching your query
+- **Recent Searches**: Previously searched items are saved and shown for quick access
+- **Search Categories**: Results are grouped by type (parks, rides, companies)
+
+### 1.2 Content Discovery Tabs
+The homepage displays curated content across **11 different discovery tabs**:
+- **Trending Parks**: Parks with the most page views in the last 30 days
+- **Trending Rides**: Rides with the most page views in the last 30 days
+- **New Parks**: Most recently added parks to the database
+- **New Rides**: Most recently added rides to the database
+- **Recent Changes**: Latest edits and updates across all entities
+- **Top Parks**: Highest-rated parks by user reviews
+- **Top Rides**: Highest-rated rides by user reviews
+- **Opening Soon**: Parks and rides with upcoming opening dates
+- **Recently Opened**: Parks and rides that just opened
+- **Closing Soon**: Parks and rides announced to close
+- **Recently Closed**: Parks and rides that have recently closed
+
+### 1.3 Recent Changes Feed
+- Shows entity type, name, change type (created/updated)
+- Displays who made the change and when
+- Links directly to the changed entity's page
+- Handles deleted entities gracefully
+
+---
+
+## **2. PARKS**
+
+### 2.1 Parks Listing Page
+- **Grid and List Views**: Toggle between card grid or compact list display
+- **Search**: Find parks by name, description, or location
+- **Extensive Filtering**:
+ - Park type (theme park, amusement park, water park, etc.)
+ - Operating status (operating, seasonal, closed, etc.)
+ - Country, state/province, city
+ - Operators and property owners
+ - Rating range (minimum to maximum)
+ - Ride count range
+ - Coaster count range
+ - Review count range
+ - Opening year range
+ - Opening date range
+ - Closing soon flag
+ - Closing date range
+- **Sorting Options**: Name, rating, ride count, opening date, etc.
+- **Collapsible Sidebar**: Save screen space by hiding filters
+- **Pagination**: Navigate through pages of results
+- **Add Park Button**: Opens submission form (requires sign-in)
+
+### 2.2 Parks Nearby Page
+- **Geolocation**: Uses your device's location to find nearby parks
+- **Location Search**: Search for any city, postal code, or address to center search
+- **Adjustable Radius**: Select search radius from 25km to unlimited
+- **Unit System Toggle**: Switch between metric (km) and imperial (miles)
+- **Interactive Map**:
+ - Shows all parks within radius as markers
+ - Markers cluster when zoomed out
+ - Click markers to see park preview
+ - Your location shown with accuracy radius
+- **Distance Display**: Each park shows distance from your location
+- **Sorting**: Sort by distance or alphabetically
+
+### 2.3 Park Detail Page
+- **Hero Banner**: Large banner image with park name and status overlay
+- **Quick Stats Cards**: Total rides, roller coasters, reviews, opening date
+- **Status Badges**: Shows operating status and park type
+- **Location Display**: City, state, country with formatted display
+- **Closing Banner**: Warning banner if park is announced to close
+- **Version Indicator**: Shows version number with history access
+- **Breadcrumb Navigation**: Hierarchical navigation back to parks list
+
+**Tabs on Park Detail:**
+- **Overview Tab**:
+ - Park description (expandable if long)
+ - Contact information (phone, website, email)
+ - Operating hours and seasonal dates
+ - Location map (OpenStreetMap embed)
+ - "Best Rated Rides" section showing top-rated rides at this park
+ - Operator and property owner information with preview hover cards
+- **Rides Tab**: All rides at this park in a grid
+- **Reviews Tab**:
+ - Average rating display
+ - Rating distribution histogram
+ - Review list with pagination
+ - Write review button (opens form)
+- **Photos Tab**: Community-submitted photos gallery
+- **History Tab**: Complete edit history and timeline events
+
+### 2.4 Park Rides Page
+- Dedicated page showing all rides at a specific park
+- Same filtering and sorting as main rides page
+- Shows ride cards with ratings and status
+
+---
+
+## **3. RIDES**
+
+### 3.1 Rides Listing Page
+- **Grid and List Views**: Toggle between views
+- **Search**: Find rides by name or description
+- **Comprehensive Filtering**:
+ - Category (roller coaster, flat ride, water ride, dark ride, etc.)
+ - Status (operating, under construction, defunct, SBNO, etc.)
+ - Park selection
+ - Country and location
+ - Manufacturer
+ - Designer
+ - Ride model
+ - Intensity level
+ - Wetness level (dry, splash, soaking, etc.)
+ - Rotation type (stationary, spinning, etc.)
+ - Transport type (chain lift, launch, etc.)
+ - Accessibility features
+ - Rating range
+ - Speed range
+ - Height range
+ - Length range
+ - Drop range
+ - Duration range
+ - Inversion count range
+ - Opening date range
+ - Closing date range
+- **Sorting**: By name, rating, speed, height, length, drop, duration, inversions
+- **Pagination**
+- **Add Ride Button**: Opens submission form
+
+### 3.2 Ride Detail Page
+- **Hero Banner**: Banner image with ride name, status, and park name overlay
+- **SBNO Banner**: Special alert if ride is "Standing But Not Operating"
+- **Closing Banner**: Warning if ride is closing
+- **Quick Stats Cards**: Speed, height, length, duration, capacity, inversions, drop, G-force
+- **Park Link**: Hover shows park preview card
+- **Version Indicator**
+
+**Tabs on Ride Detail:**
+- **Overview Tab**:
+ - Description
+ - Ride highlights (thrill features, records, unique elements)
+ - Former names history (if renamed)
+ - Similar rides suggestions
+ - Recent photos preview
+ - Manufacturer and designer information with hover cards
+ - Ride model link if applicable
+ - Location map
+- **Specifications Tab**:
+ - All technical specifications in organized sections
+ - Measurements displayed in user's preferred units
+ - Height requirements
+ - Capacity information
+ - Accessibility notes
+ - Roller coaster-specific stats (track type, elements, etc.)
+- **Reviews Tab**: Same as parks
+- **Photos Tab**: Community photos
+- **History Tab**: Edit history and timeline
+
+---
+
+## **4. COMPANIES**
+
+Companies are categorized into four types with separate listing and detail pages:
+
+### 4.1 Manufacturers
+- Companies that build rides
+- Listing with filtering by country, status
+- Detail page showing:
+ - Company description
+ - Contact information
+ - Website
+ - Rides they manufactured (with link to full list)
+ - Ride models they produce (with link to full list)
+
+### 4.2 Designers
+- Companies that design rides
+- Listing and detail pages
+- Detail page shows rides they designed
+
+### 4.3 Operators
+- Companies that operate parks
+- Listing and detail pages
+- Detail page shows parks they operate
+
+### 4.4 Property Owners
+- Companies or entities that own parks
+- Listing and detail pages
+- Detail page shows parks they own
+
+### 4.5 Company Hover Cards
+- Throughout the app, company names show preview cards on hover
+- Preview includes logo, type, headquarters location, and key stats
+
+---
+
+## **5. RIDE MODELS**
+
+### 5.1 Manufacturer Models Page
+- Accessed from manufacturer detail page
+- Lists all ride models that manufacturer produces
+- Shows model name, description, and count of installations
+
+### 5.2 Ride Model Detail Page
+- Model description and specifications
+- Template specifications that all installations share
+- List of all rides of this model worldwide
+- Photo gallery
+- History timeline
+
+---
+
+## **6. SEARCH**
+
+### 6.1 Global Search Page
+- Full-page search with larger result area
+- Type tabs: All, Parks, Rides, Companies
+- Result counts per type
+
+### 6.2 Search Filtering
+- **Location Filters**: Country, state/province
+- **Rating Range**: Minimum and maximum
+- **Toggle Filters Panel**: Show/hide for more screen space
+
+### 6.3 Search Sorting
+- Relevance
+- Name
+- Rating
+- Review count
+- Ride count (for parks)
+- Opening date
+
+### 6.4 Search Results
+- Rich result cards with images, ratings, and type indicators
+- Quick actions per result
+- Empty state with suggestions
+
+---
+
+## **7. USER AUTHENTICATION**
+
+### 7.1 Sign In Options
+- **Email/Password**: Traditional login
+- **Magic Link**: Passwordless email sign-in
+- **Google OAuth**: Sign in with Google
+- **Discord OAuth**: Sign in with Discord
+
+### 7.2 Sign Up
+- Email, password, username, display name
+- CAPTCHA verification (Cloudflare Turnstile)
+- Email confirmation required
+
+### 7.3 Multi-Factor Authentication (MFA)
+- TOTP-based (authenticator app)
+- Required for admin and moderator accounts
+- MFA challenge modal during login
+- Session AAL (Authentication Assurance Level) tracking
+
+### 7.4 Account Security Features
+- Session management (view/revoke active sessions)
+- Password change with old password verification
+- Add password to OAuth-only accounts
+- Ban detection on login (prevents banned users)
+- Storage warning (if localStorage disabled)
+
+---
+
+## **8. USER PROFILE**
+
+### 8.1 Profile Page
+- **Profile Header**: Avatar, display name, username, join date
+- **Profile Bio**: User description
+- **Location Display** (if privacy allows)
+- **Role Badge**: Displays if moderator/admin
+- **Edit Profile Button** (own profile only)
+- **Block User Button** (other profiles)
+
+### 8.2 Profile Statistics
+- Unique ride credits
+- Total rides taken
+- Parks visited
+- Coasters ridden
+
+### 8.3 Profile Tabs
+- **Activity Tab**:
+ - Recent submissions (parks, rides, photos, etc.)
+ - Recent rankings/lists
+ - Recent reviews
+ - Recent ride credits added
+ - Each activity shows status (pending, approved, rejected)
+- **Reviews Tab**: All user's reviews
+- **Lists Tab**: User's custom lists
+- **Ride Credits Tab**: User's ride history
+
+### 8.4 Profile Editing
+- Change display name
+- Change username (with confirmation dialog - limited changes)
+- Update bio
+- Upload/change avatar photo
+- Profile shows preview in real-time
+
+---
+
+## **9. USER SETTINGS**
+
+### 9.1 Account & Profile Tab
+- View/change email address (requires verification)
+- View linked authentication providers
+- Profile picture management
+- Display name editing
+
+### 9.2 Security Tab
+- Password management
+ - Change existing password
+ - Add password to OAuth account
+- Multi-Factor Authentication
+ - Enroll new TOTP authenticator
+ - View enrolled factors
+ - Remove MFA factors
+- Active Sessions
+ - View all logged-in devices/browsers
+ - Revoke individual sessions
+ - Session shows device info, location, last active
+
+### 9.3 Privacy Tab
+- Profile visibility (public, registered users only, private)
+- Location visibility
+- Show/hide email on profile
+- Data deletion options
+
+### 9.4 Notifications Tab
+- Email notification preferences
+- In-app notification preferences
+- Digest frequency settings
+- Notification rate limiting
+
+### 9.5 Location & Info Tab
+- Personal location (displayed on profile)
+- Home park selection
+- Timezone setting
+- Biography/about text
+
+### 9.6 Data & Export Tab
+- **Data Export**: Download all your data as JSON
+ - Profile information
+ - Reviews
+ - Ride credits
+ - Lists
+ - Submissions
+- **Account Deletion**:
+ - Request account deletion
+ - 7-day grace period
+ - Email verification required
+ - Cancel pending deletion
+ - Scheduled deletion date shown
+
+---
+
+## **10. RIDE CREDITS (RIDE COUNT TRACKING)**
+
+### 10.1 Credits Overview
+- Total ride count (sum of all rides taken)
+- Unique credits (unique rides)
+- Parks visited
+- Coasters ridden
+
+### 10.2 Credit Cards
+- Each credit shows:
+ - Ride name and park
+ - Ride category and type icons
+ - First ride date
+ - Total ride count
+ - User rating
+ - Personal notes
+ - Card/banner image
+
+### 10.3 Credit Management
+- **Add Credit**: Search and select ride, enter date and count
+- **Edit Credit**: Update count, date, notes
+- **Delete Credit**: Remove with confirmation
+- **Reorder Credits**: Drag-and-drop to arrange custom order
+
+### 10.4 Credit Filtering
+- By category (roller coaster, flat ride, etc.)
+- By park
+- By manufacturer
+- By country/region
+- By ride status
+- By intensity level
+
+### 10.5 Credit Sorting
+- Custom order (manual arrangement)
+- Date (first ride date)
+- Count (times ridden)
+- Alphabetical
+
+### 10.6 Credit Views
+- Grid view (card layout)
+- List view (compact table)
+- Edit mode (drag handles visible)
+
+---
+
+## **11. USER LISTS (RANKINGS)**
+
+### 11.1 List Creation
+- Title and description
+- List type (parks, rides, coasters, companies, or mixed)
+- Public/private visibility toggle
+
+### 11.2 List Types
+- Parks only
+- Rides only
+- Coasters only
+- Companies only
+- Mixed (any combination)
+
+### 11.3 List Item Management
+- Search and add items
+- Add personal notes per item
+- Reorder items by dragging
+- Remove items
+- Automatic position numbering
+
+### 11.4 List Display
+- Numbered ranking display
+- Entity images and basic info
+- Notes visible
+- Links to entity pages
+
+### 11.5 List Visibility
+- Toggle public/private per list
+- Eye icon indicates current visibility
+- Private lists only visible to owner
+
+---
+
+## **12. REVIEWS & RATINGS**
+
+### 12.1 Review Creation
+- Star rating (1-5 stars, half-star precision)
+- Review title
+- Review content (text body)
+- Submit for moderation
+
+### 12.2 Reviews Display
+- Star rating visualization
+- Reviewer info (username, avatar)
+- Review date
+- Review content
+- Helpful vote count
+
+### 12.3 Review Interaction
+- **Helpful Vote**: Mark reviews as helpful
+- **Edit**: Edit your own reviews
+- **Delete**: Delete your own reviews
+- Vote count display per review
+
+### 12.4 Rating Statistics
+- Average rating (displayed on entity pages)
+- Review count
+- Rating distribution chart (1-5 stars histogram)
+- Rating shown on cards throughout app
+
+### 12.5 Review Moderation
+- All reviews go through moderation queue
+- Approved reviews become visible
+- Rejected reviews with reason sent to user
+
+---
+
+## **13. PHOTOS**
+
+### 13.1 Photo Upload
+- **Drag and Drop**: Drop files onto upload zone
+- **File Browser**: Click to select files
+- **Multiple Upload**: Upload up to 10 photos at once
+- **Image Editor**: Crop, rotate, adjust before upload
+- **Caption Editor**: Add captions to each photo
+- **Progress Indicator**: Shows upload progress
+
+### 13.2 Photo Gallery
+- Grid layout with thumbnails
+- Click to view full-size in modal
+- Navigation between photos (prev/next)
+- Photo credits shown
+- Captions displayed
+- "Set as Banner/Card" option for entity owners
+
+### 13.3 Photo Management (Entity Pages)
+- **Add Photos Button**: Opens upload dialog
+- **Photo Management Dialog**:
+ - Delete photos
+ - Update captions
+ - Set as banner image
+ - Set as card image
+
+### 13.4 Photo Submissions
+- All photos go through moderation
+- Submission preview shows thumbnails
+- Batch approval/rejection
+- Reviewer notes for rejections
+
+---
+
+## **14. CONTENT SUBMISSION SYSTEM**
+
+### 14.1 The Sacred Pipeline
+Every piece of user content follows this flow:
+1. User fills out form
+2. Submission record created
+3. Enters moderation queue
+4. Moderator claims submission
+5. Review and approve/reject
+6. If approved: entity version created, public content updated
+7. Visible on site
+
+### 14.2 Park Submission Form
+- **Basic Info Section**:
+ - Name (required)
+ - Slug (auto-generated, editable)
+ - Description
+ - Park type dropdown
+ - Status dropdown
+- **Location Section**:
+ - Location search (autocomplete)
+ - Or create new location inline
+ - Country, state, city, postal code
+ - Coordinates (latitude/longitude)
+ - Timezone
+- **Dates Section**:
+ - Opening date (with precision: exact, month, year)
+ - Closing date (with precision)
+ - Is closing flag
+- **Media Section**:
+ - Banner image upload
+ - Card image upload
+- **Contact Section**:
+ - Phone
+ - Email
+ - Website
+- **Relationships Section**:
+ - Operator (select or create new)
+ - Property owner (select or create new)
+- **Moderation Section**:
+ - Submission notes (explain changes)
+ - Test data flag (for testing)
+
+### 14.3 Ride Submission Form
+- **Basic Info**:
+ - Name
+ - Slug
+ - Description
+ - Category dropdown
+ - Status dropdown
+- **Park Selection**:
+ - Search existing parks
+ - Or create new park inline
+- **Specifications** (all with unit conversion):
+ - Maximum speed
+ - Height
+ - Length
+ - Drop height
+ - Duration
+ - Capacity per hour
+ - Inversions count
+ - G-force
+ - Minimum height requirement
+- **Ride Attributes**:
+ - Intensity level
+ - Wetness level
+ - Rotation type
+ - Transport type (lift type)
+ - Accessibility features
+- **Roller Coaster Specific** (if category is coaster):
+ - Coaster type (steel, wood, hybrid)
+ - Seating type (sit-down, inverted, flying, etc.)
+ - Track layout (out and back, twister, etc.)
+ - Additional technical specs
+- **Relationships**:
+ - Manufacturer (select or create)
+ - Designer (select or create)
+ - Ride model (select or create)
+- **Dates**:
+ - Opening date with precision
+ - Closing date with precision
+- **Media**:
+ - Banner and card images
+- **Former Names**:
+ - Add name history with date ranges
+- **Technical Specifications Editor**:
+ - Add custom specs (name, value, unit)
+ - Organized by categories
+
+### 14.4 Company Submission Forms
+Separate forms for each company type:
+- Manufacturer form
+- Designer form
+- Operator form
+- Property owner form
+
+Each includes:
+- Name and slug
+- Description
+- Company type
+- Headquarters location
+- Website
+- Logo image
+
+### 14.5 Ride Model Submission Form
+- Model name
+- Description
+- Associated manufacturer
+- Template specifications
+- Photos
+
+### 14.6 Composite Submissions
+When creating a new entity that references another non-existent entity:
+- Create new park inline while submitting ride
+- Create new manufacturer inline while submitting ride
+- Create new operator inline while submitting park
+- All linked entities bundled into single submission
+- Moderator can approve/reject individual parts
+
+### 14.7 Form Features
+- **Auto-save Drafts**: Work saved locally
+- **JSON Import**: Import sample data or paste JSON
+- **Sample Data Loading**: Pre-fill with example data
+- **Unit System Toggle**: Switch between metric/imperial while editing
+- **Unit Conversion Preview**: See both units in real-time
+- **Field Validation**: Real-time error highlighting
+- **Error Summary**: Collapsible list of all validation errors
+- **Help Tooltips**: Explanations for complex fields
+- **Submission Help Dialog**: Explains moderation process
+
+---
+
+## **15. VERSIONING & HISTORY**
+
+### 15.1 Version Indicator
+- Displayed on entity pages
+- Shows version number
+- Indicates last change date
+- Click opens history panel
+
+### 15.2 Entity History Tabs
+- **Edit History Tab**:
+ - Chronological list of all versions
+ - Each version shows:
+ - Version number
+ - Change type (created, updated, deleted)
+ - User who made change
+ - Change timestamp
+ - Change reason
+ - Select two versions to compare
+ - "Restore" button (moderators only)
+- **Timeline Tab**:
+ - Historical events for the entity
+ - Events like "renamed", "relocated", "record broken"
+ - Each event has date and description
+
+### 15.3 Version Comparison
+- Side-by-side field comparison
+- Visual diff highlighting:
+ - Green: Added fields
+ - Red: Removed fields
+ - Yellow: Modified fields
+- Old value vs new value display
+- Handles complex fields (locations, specs)
+
+### 15.4 Rollback/Restore
+- Moderators can restore previous versions
+- Creates new submission for review
+- Audit trail maintained
+
+---
+
+## **16. MODERATION SYSTEM**
+
+### 16.1 Moderation Queue
+- **Queue Statistics Cards**:
+ - Pending submissions count
+ - Pending reports count
+ - Flagged content count
+- **Queue Tabs**:
+ - Moderation (submissions)
+ - Reports (user reports)
+ - Activity (recent actions)
+
+### 16.2 Queue Filtering
+- Entity type (all, parks, rides, companies, etc.)
+- Status (pending, approved, rejected)
+- Date range
+- Submitter
+
+### 16.3 Queue Sorting
+- Created date
+- Priority
+- Submitter
+- Type
+
+### 16.4 Queue Items Display
+Each item shows:
+- Submitter username and avatar
+- Submission type badge
+- Entity name
+- Action type (create/edit)
+- Created timestamp
+- Status badge
+- Lock indicator (if claimed)
+- Preview of changes
+
+### 16.5 Claim System
+- Moderator must claim submission before acting
+- Claim lock shown to other moderators
+- Lock expires after 30 minutes
+- Force release available for admins
+- Prevents conflicts between moderators
+
+### 16.6 Submission Review
+- **Full Data Preview**: See all submitted data
+- **Change Diffs**: For edits, shows what changed
+- **Field Comparison**: Side-by-side old/new values
+- **Photo Preview**: Thumbnails of submitted photos
+- **Location Preview**: Map for location changes
+- **Validation Status**: Shows any validation warnings
+
+### 16.7 Approval Actions
+- **Approve All**: Approve entire submission
+- **Selective Approval**: Approve specific items only
+- **Reject All**: Reject entire submission
+- **Selective Rejection**: Reject specific items
+- **Request Changes**: Ask user for modifications
+- **Reviewer Notes**: Add notes for decision
+
+### 16.8 Bulk Operations
+- Select multiple submissions
+- Bulk approve selected
+- Bulk reject selected
+- Results dialog shows success/failure per item
+
+### 16.9 Conversion Tool
+- Convert "create" submission to "edit"
+- When duplicate entity found
+- Links to existing entity
+- Preserves submitted data
+
+### 16.10 Rejection Dialog
+- Select rejection reason from presets
+- Add custom notes
+- Notification sent to submitter
+
+### 16.11 Reports Queue
+- User-submitted reports of content issues
+- Report type (inaccurate, inappropriate, etc.)
+- Report description
+- Link to reported entity
+- Actions: Dismiss, Take action, Escalate
+
+### 16.12 Recent Activity
+- Log of recent moderation actions
+- Shows moderator, action, entity, timestamp
+- Filterable by action type
+
+### 16.13 Moderation Audit Log
+- Complete audit trail
+- All approval/rejection decisions
+- Moderator identification
+- Timestamps
+- Notes and reasons
+
+---
+
+## **17. ADMIN FEATURES**
+
+### 17.1 User Management
+- **User List**: All users with search
+- **User Details**: Profile, activity, roles
+- **Role Management**:
+ - Assign roles: user, moderator, admin
+ - Role requires confirmation
+ - MFA requirement enforcement
+- **User Actions**:
+ - Ban user (with reason)
+ - Unban user
+ - Delete user (with confirmation)
+ - View user's submissions
+
+### 17.2 Admin Dashboard
+- Overview statistics
+- Pending queue counts
+- System health indicators
+- Quick access cards to sections
+
+### 17.3 Admin Settings
+- **Moderation Settings**:
+ - Auto-refresh mode (on/off)
+ - Polling interval
+ - Default sort order
+- **Notification Settings**:
+ - Admin alert thresholds
+ - Email notification toggles
+- **System Settings**:
+ - Maintenance mode toggle
+ - Feature flags
+
+### 17.4 Blog Management
+- Create blog posts
+- Edit existing posts
+- Publish/unpublish
+- SEO settings per post
+
+### 17.5 Support/Contact
+- View contact form submissions
+- Reply to messages
+- Merge duplicate tickets
+- Mark as resolved
+
+### 17.6 Monitoring Hub
+- **Overview**: System health dashboard
+- **Database Stats**:
+ - Table sizes
+ - Row counts
+ - Growth trends
+ - Entity comparisons
+ - Top contributors
+ - Data quality metrics
+- **Approval History**:
+ - Historical approval data
+ - Date range filtering
+ - Export functionality
+- **Edge Function Logs**:
+ - Live logs from backend functions
+ - Filter by function name
+ - Search log content
+ - Error highlighting
+
+### 17.7 Error Monitoring
+- **Error Lookup**: Search errors by ID
+- **Error List**: Recent errors with details
+- **Error Details Modal**:
+ - Error message
+ - Stack trace
+ - User context
+ - Request metadata
+ - Submission links
+
+### 17.8 Trace Viewer
+- View distributed traces
+- Span hierarchy visualization
+- Timing breakdown
+- Debug backend operations
+
+### 17.9 Database Maintenance
+- **Location Deduplication**: Find and merge duplicate locations
+- **Orphaned Image Cleanup**: Remove unused uploaded images
+- **Quick Undo Merge**: Rollback recent location merges
+- **Data Retention Panel**: Manage data cleanup schedules
+
+### 17.10 Database Manager
+- Direct entity management (emergency use)
+- Create entities bypassing moderation (emergency)
+- Delete entities with cascade
+- Reserved for critical fixes only
+
+### 17.11 SEO Audit
+- Check all entities for SEO issues
+- Missing titles, descriptions
+- Image alt text verification
+- Slug validation
+
+### 17.12 OG Tag Preview
+- Preview Open Graph tags for any page
+- Social media share previews
+- Twitter card preview
+- Debug missing data
+
+### 17.13 OpenStreetMap Usage
+- Track API usage
+- Rate limit monitoring
+- Quota tracking
+
+---
+
+## **18. ERROR HANDLING**
+
+### 18.1 User Error Experience
+- Generic error messages only
+- "Something went wrong. Please try again."
+- Reference ID provided for support
+- No technical details exposed
+
+### 18.2 Admin/Moderator Error Experience
+- **Comprehensive Error Modal**:
+ - Full error message
+ - Stack trace
+ - Error classification
+ - Request context
+ - User information
+ - Submission IDs
+ - Suggested actions
+
+### 18.3 Error Logging
+- All errors logged to database
+- Error monitoring dashboard
+- Searchable by ID
+- Context preservation
+
+---
+
+## **19. MAPS & LOCATION**
+
+### 19.1 OpenStreetMap Integration
+- Embedded maps on entity pages
+- Interactive maps for nearby parks
+- Map markers with popups
+
+### 19.2 Location Search
+- Autocomplete location search
+- Results from OpenStreetMap Nominatim
+- City, state, country extraction
+- Coordinate extraction
+
+### 19.3 Geolocation
+- Browser geolocation support
+- Accuracy display
+- Permission handling
+- Fallback for denied permission
+
+### 19.4 Interactive Parks Map
+- Leaflet-based interactive map
+- Marker clustering
+- Park preview popups
+- User location marker
+- Radius circle overlay
+- Zoom controls
+
+### 19.5 Location Enrichment
+- Automatic timezone detection
+- Country/state parsing
+- Coordinate validation
+
+---
+
+## **20. MEASUREMENT UNITS**
+
+### 20.1 Unit Preferences
+- **Metric**: km/h, meters, centimeters, kilograms
+- **Imperial**: mph, feet, inches, pounds
+- Preference stored per-user
+- Anonymous users: auto-detect by location
+
+### 20.2 Unit Display
+- All measurements shown in user's preferred units
+- Automatic conversion from stored metric values
+- Unit labels included
+
+### 20.3 Form Unit Handling
+- Input in either unit system
+- Live conversion preview
+- Stored always in metric
+- Unit toggle in forms
+
+### 20.4 Unit Types
+- Speed (km/h ↔ mph)
+- Distance (m ↔ ft)
+- Large distance (km ↔ mi)
+- Height/short (cm ↔ in)
+- Weight (kg ↔ lbs)
+
+---
+
+## **21. NOTIFICATIONS**
+
+### 21.1 In-App Notifications
+- Notification bell in header
+- Unread count badge
+- Notification feed
+- Mark as read/unread
+- Click to navigate
+
+### 21.2 Email Notifications
+- Submission status updates
+- Review responses
+- Admin alerts
+- System announcements
+
+### 21.3 Notification Types
+- Submission approved/rejected
+- New review on your entity
+- Role changes
+- System alerts
+
+### 21.4 Notification Preferences
+- Per-notification-type toggles
+- Email on/off
+- In-app on/off
+- Digest frequency
+
+---
+
+## **22. BLOG**
+
+### 22.1 Blog Index
+- List of published posts
+- Featured image
+- Excerpt
+- Author and date
+- Categories/tags
+
+### 22.2 Blog Post Page
+- Full content
+- Author information
+- Related posts
+- Social sharing
+
+### 22.3 Blog Admin
+- Create/edit posts
+- Draft/publish status
+- Scheduled publishing
+- SEO settings
+
+---
+
+## **23. STATIC PAGES**
+
+### 23.1 Terms of Service
+- Legal terms
+- Formatted content
+
+### 23.2 Privacy Policy
+- Data handling explanation
+- Cookie policy
+- User rights
+
+### 23.3 Submission Guidelines
+- How to contribute
+- Quality standards
+- Moderation process explanation
+- Tips for approval
+
+### 23.4 Contact Page
+- Contact form
+- Support information
+- Response time expectations
+
+---
+
+## **24. REPORTING SYSTEM**
+
+### 24.1 Report Button
+- Available on entities and reviews
+- Opens report dialog
+
+### 24.2 Report Types
+- Inaccurate information
+- Inappropriate content
+- Duplicate entry
+- Spam
+- Other
+
+### 24.3 Report Form
+- Report type selection
+- Description field
+- Submit button
+
+### 24.4 Report Processing
+- Appears in admin reports queue
+- Review and action
+- Close report
+
+---
+
+## **25. THEME & UI**
+
+### 25.1 Light/Dark Mode
+- System preference detection
+- Manual toggle
+- Persisted preference
+- Smooth transition
+
+### 25.2 Responsive Design
+- Mobile-optimized layouts
+- Touch-friendly interactions
+- Collapsible sidebars on mobile
+- Swipeable tab content
+- Mobile-specific navigation
+
+### 25.3 Loading States
+- Skeleton loaders for content
+- Loading spinners for actions
+- Progress indicators for uploads
+- Shimmer effects
+
+### 25.4 Empty States
+- Friendly illustrations
+- Helpful messages
+- Action suggestions
+
+---
+
+## **26. SEO & SHARING**
+
+### 26.1 Open Graph Tags
+- Dynamic OG images
+- Page-specific titles
+- Descriptions
+- Type metadata
+
+### 26.2 URL Structure
+- `/parks` - Parks listing
+- `/parks/{slug}` - Park detail
+- `/parks/{slug}/rides` - Park's rides
+- `/parks/{slug}/rides/{slug}` - Ride at park
+- `/parks/nearby` - Nearby parks
+- `/rides` - Rides listing
+- `/manufacturers` - Manufacturer listing
+- `/manufacturers/{slug}` - Manufacturer detail
+- `/manufacturers/{slug}/rides` - Manufacturer's rides
+- `/manufacturers/{slug}/models` - Manufacturer's models
+- `/manufacturers/{slug}/models/{slug}` - Model detail
+- `/designers`, `/owners`, `/operators` - Similar patterns
+- `/profile/{username}` - User profile
+- `/settings` - User settings
+- `/admin` - Admin dashboard
+- `/search` - Search page
+- `/blog` - Blog
+
+### 26.3 Sitemap
+- Dynamic sitemap generation
+- All entities included
+- Priority settings
+
+---
+
+## **27. PERFORMANCE**
+
+### 27.1 Lazy Loading
+- Pages lazy loaded on navigation
+- Images lazy loaded on scroll
+- Tabs load content on activation
+
+### 27.2 Caching
+- React Query data caching
+- Stale-while-revalidate
+- Optimistic updates
+
+### 27.3 Prefetching
+- Route prefetching on hover
+- Related data prefetching
+- Search result preloading
+
+---
+
+## **28. ACCESSIBILITY**
+
+### 28.1 Keyboard Navigation
+- Full keyboard support
+- Focus indicators
+- Tab order management
+
+### 28.2 Screen Reader Support
+- Semantic HTML
+- ARIA labels
+- Alt text for images
+- Announcements for updates
+
+### 28.3 Visual Accessibility
+- High contrast modes
+- Scalable text
+- Color not sole indicator
+
+---