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 + +---