feat: Add blog, media, and support apps, implement ride credits and image API, and remove toplist feature.

This commit is contained in:
pacnpal
2025-12-26 15:15:28 -05:00
parent cd8868a591
commit 00699d53b4
77 changed files with 7274 additions and 538 deletions

View File

@@ -4,45 +4,92 @@
### 1. `apps.core`
- **Responsibility**: Base classes, shared utilities, history tracking.
- **Existing**: `SluggedModel`, `TrackedModel`, `pghistory`.
- **Existing**: `SluggedModel`, `TrackedModel`.
- **Versioning Strategy (Section 15)**:
- All core entities (`Park`, `Ride`, `Company`) must utilize `django-pghistory` or `apps.core` tracking to support:
- **Edit History**: Chronological list of changes with `reason`, `user`, and `diff`.
- **Timeline**: Major events (renames, relocations).
- **Rollback**: Ability to restore previous versions via the Moderation Queue.
### 2. `apps.accounts`
- **Responsibility**: User authentication and profiles.
- **Existing**: `User`, `UserProfile`, `UserDeletionRequest`, `UserNotification`.
- **Missing**: `RideCredit` (although `UserProfile` has aggregate stats, individual credits are needed).
- **Responsibility**: User authentication, profiles, and settings.
- **Existing**: `User`, `UserProfile` (bio, location, home park).
- **Required Additions (Section 9)**:
- **UserDeletionRequest**: Support 7-day grace period for account deletion.
- **Privacy Settings**: Fields for `is_profile_public`, `show_location`, `show_email` on `UserProfile`.
- **Data Export**: Serializers/Utilities to dump all user data (Reviews, Credits, Lists) to JSON.
### 3. `apps.parks`
- **Responsibility**: Park management.
- **Existing**: `Park`, `ParkArea`.
- **Models**: `Park`, `ParkArea`.
- **Relationships**:
- `operator`: FK to `apps.companies.Company` (Type: Operator).
- `property_owner`: FK to `apps.companies.Company` (Type: Owner).
### 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).
- **Responsibility**: Ride data, Coasters, and Credits.
- **Models**:
- `Ride`: Core entity (Status FSM: Operating, SBNO, Closed, etc.).
- `RideModel`: Defines the "Type" of ride (e.g., B&M Hyper V2).
- `Manufacturer`: FK to `apps.companies.Company`.
- `Designer`: FK to `apps.companies.Company`.
- **Ride Credits (Section 10)**:
- **Model**: `RideCredit` (Through-Model: `User` <-> `Ride`).
- **Fields**:
- `count` (Integer): Total times ridden.
- `rating` (Float): Personal rating (distinct from public Review).
- `first_ridden_at` (Date): First time experiencing the ride.
- `notes` (Text): Private personal notes.
- **Constraints**: `Unique(user, ride)` - A user has one credit entry per ride.
### 5. `apps.moderation`
- **Responsibility**: Content pipeline.
- **Existing**: Check `apps/moderation` (likely `Submission`).
- **Constraint**: Status State Machine (Pending -> Claimed -> Approved).
### 5. `apps.companies`
- **Responsibility**: Management of Industry Entities (Section 4).
- **Models**:
- `Company`: Single model with `type` choices or Polymorphic.
- **Types**: `Manufacturer`, `Designer`, `Operator`, `PropertyOwner`.
- **Features**: Detailed pages, hover cards, listing by type.
### 6. `apps.media`
- **Responsibility**: Photos and Videos.
- **Existing**: `Photo` (GenericFK).
### 6. `apps.moderation` (The Sacred Submission Pipeline)
- **Responsibility**: Centralized Content Submission System (Section 14, 16).
- **Concept**: **Live Data** (Approve) vs **Submission Data** (Pending).
- **Models**:
- `Submission`:
- `submitter`: FK to User.
- `content_type`: Target Model (Park, Ride, etc.).
- `object_id`: Target ID (Null for Creation).
- `data`: **JSONField** storing the proposed state.
- `status`: State Machine (`Pending` -> `Claimed` -> `Approved` | `Rejected` | `ChangesRequested`).
- `moderator`: FK to User (Claimaint).
- `moderator_note`: Reason for rejection/feedback.
- `Report`: User flags on content.
- **Workflow**:
1. User submits form -> `Submission` created (Status: Pending).
2. Moderator Claims -> Status: Claimed.
3. Approves -> Applies `data` to `Live Model` -> Saves Version -> Status: Approved.
### 7. `apps.reviews`
- **Responsibility**: User reviews.
- **Proposed**: `Review` model (User, Entity GenericFK, rating 1-5, text, helpful votes).
### 7. `apps.media`
- **Responsibility**: Media Management (Section 13).
- **Models**:
- `Photo`: GenericFK. Fields: `image`, `caption`, `user`, `status` (Moderation).
- **Banner/Card**: Entities should link to a "Primary Photo" or store a cached image field.
### 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.
### 8. `apps.reviews`
- **Responsibility**: Public Reviews & Ratings (Section 12).
- **Models**:
- `Review`: GenericFK (Park, Ride).
- **Fields**: `rating` (1-5, 0.5 steps), `title`, `body`, `helpful_votes`.
- **Logic**: Aggregates (Avg Rating, Count) calculation for Entity caches.
### 9. `apps.blog`
- **Responsibility**: Blog posts.
- **Proposed**: `Post`, `Tag`.
### 9. `apps.lists`
- **Responsibility**: User Lists & Rankings (Section 11).
- **Models**:
- `UserList`: Title, Description, Type (Park/Ride/Coaster/Mixed), Privacy (Public/Private).
- `UserListItem`: FK to List, GenericFK to Item, Order, Notes.
### 10. `apps.support`
- **Responsibility**: Contact form.
- **Proposed**: `Ticket`.
### 10. `apps.blog`
- **Responsibility**: News & Updates.
- **Models**: `Post`, `Tag`.
### 11. `apps.support`
- **Responsibility**: Human interaction.
- **Models**: `Ticket` (Contact Form).

View File

@@ -2,67 +2,112 @@
## 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.
> **Measurement Unit System**: The backend will store all values in **Metric**. The Frontend (`useUnits` composable) will handle conversion to Imperial based on user preference.
> **Sacred Pipeline Enforcement**: All user edits create `Submission` records (stored as JSON). No direct database edits are allowed for non-admin users.
## 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).
#### 1. Core & Auth Infrastructure
- [x] **`apps.core`**: Implement `TrackedModel` using `pghistory` for all core entities to support Edit History and Versioning (Section 15).
- [x] **`apps.accounts`**:
- `User` & `UserProfile` models (Bio, Location, Home Park).
- **Settings Support**: Endpoints for changing Email, Password, MFA, and Sessions (Section 9.1-9.2).
- **Privacy**: Fields for `public_profile`, `show_location`, etc. (Section 9.3).
- **Data Export**: Endpoint to generate JSON dump of all user data (Section 9.6).
- **Account Deletion**: `UserDeletionRequest` model with 7-day grace period (Section 9.6).
#### 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`).
#### 2. Entity Models & Logic ("Live" Data)
- [x] **`apps.parks`**: `Park` (with Operator/Owner FKs, Geolocation).
- [x] **`apps.rides`**: `Ride` (Status FSM), `RideModel`, `Manufacturer`, `Designer`.
- [x] **`apps.rides` (Credits)**: `RideCredit` Through-Model with `count`, `rating`, `date`, `notes`. Constraint: Unique(user, ride).
- [x] **`apps.companies`**: `Company` model with types (`Manufacturer`, `Designer`, `Operator`, `Owner`).
- [x] **`apps.lists`**: `UserList` (Ranking System) and `UserListItem`.
- [x] **`apps.reviews`**: `Review` model (GenericFK) with Aggregation Logic.
#### 3. The Sacred Pipeline (`apps.moderation`)
- [x] **Submission Model**: Stores `changes` (JSON), `status` (State Machine), `moderator_note`.
- [x] **Submission Serializers**: Handle validation of "Proposed Data" vs "Live Data".
- [x] **Queue Endpoints**: `list_pending`, `claim`, `approve`, `reject`, `activity_log`, `stats`.
- [x] **Reports**: `Report` model and endpoints.
### 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.
#### 1. Initial Setup & Core
- [x] **Composables**: `useUnits` (Metric/Imperial), `useAuth` (MFA, Session), `useApi`.
- [x] **Layouts**: Standard Layout (Hero, Tabs), Auth Layout.
#### 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.
#### 2. Discovery & Search (Section 1 & 6)
- [x] **Global Search**: Hero Search with Autocomplete (Parks, Rides, Companies).
- [x] **Discovery Tabs** (11 Sections):
- [x] Trending Parks / Rides
- [x] New Parks / Rides
- [x] Top Parks / Rides
- [x] Opening Soon / Recently Opened
- [x] Closing Soon / Recently Closed
- [x] Recent Changes Feed
#### Design System
- **Theme**: Dark/Light mode support (built-in to Nuxt UI).
- **Components**:
- `EntityCard` (Park/Ride summary).
- `StandardLayout` (Hero, Tabs, Content).
- `MediaGallery`.
- `ReviewList`.
#### 3. Content Pages (Read-Only Views)
- [ ] **Park Detail**: Tabs (Overview, Rides, Reviews, Photos, History).
- [ ] **Ride Detail**: Tabs (Overview, Specifications, Reviews, Photos, History).
- [ ] **Company Pages**: Manufacturer, Designer, Operator, Property Owner details.
- [ ] **Maps**: Interactive "Parks Nearby" map.
#### 4. The Sacred Submission Pipeline (Write Views)
- [ ] **Submission Forms** (Multi-step Wizards):
- [ ] **Park Form**: Location, Dates, Media, Relations.
- [ ] **Ride Form**: Specs (with Unit Toggle), Relations, Park selection.
- [ ] **Company Form**: Type selection, HQ, details.
- [ ] **Photo Upload**: Bulk upload, captioning, crop.
- [ ] **Editing**: Load existing data into form -> Submit as JSON Diff.
#### 5. Moderation Interface (Section 16)
- [ ] **Dashboard**: Queue stats, Assignments.
- [ ] **Queues**:
- [ ] **Pending Queue**: Filter by Type, Submitter, Date.
- [ ] **Reports Queue**.
- [ ] **Audit Log**.
- [ ] **Review Workspace**:
- [ ] **Diff Viewer**: Visual Old vs New comparison.
- [ ] **Actions**: Claim, Approve, Reject (with reason), Edit.
#### 6. User Experience & Settings
- [ ] **User Profile**: Activity Feed, Credits Tab, Lists Tab, Reviews Tab.
- [ ] **Ride Credits Management**: Add/Edit Credit (Date, Count, Notes).
- [ ] **Settings Area** (6 Tabs):
- [ ] Account & Profile (Edit generic info).
- [ ] Security (MFA setup, Active Sessions).
- [ ] Privacy (Visibility settings).
- [ ] Notifications.
- [ ] Location & Info (Timezone, Home Park).
- [ ] Data & Export (JSON Download, Delete Account).
#### 7. Lists System
- [ ] **List Management**: Create/Edit Lists (Public/Private).
- [ ] **List Editor**: Search items, Add to list, Drag-and-drop reorder, Add notes.
## 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).
- **Backend**: `pytest` for all Model constraints and API permissions.
- Test Submission State Machine: `Pending -> Claimed -> Approved`.
- Test Versioning: Ensure `pghistory` tracks changes on approval.
- **Frontend**: `vitest` for Unit Tests (Composables).
### 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).
### Manual Verification Flows
1. **Sacred Pipeline Flow**:
- **User**: Submit a change to "Top Thrill 2" (add stats).
- **Moderator**: Go to Queue -> Claim -> Verify Diff -> Approve.
- **Public**: Verify "Top Thrill 2" page shows new stats and "Last Updated" is now.
- **History**: Verify "History" tab shows the update event.
2. **Ride Credits**:
- Go to "Iron Gwazi" page.
- Click "Add to Credits" -> Enter `Count: 5`, `Rating: 4.5`.
- Go to Profile -> Ride Credits. Verify Iron Gwazi is listed with correct data.
3. **Data Privacy & Export**:
- Go to Settings -> Privacy -> Toggle "Private Profile".
- Open Profile URL in Incognito -> Verify 404 or "Private" message.
- Go to Settings -> Data -> "Download Data" -> Verify JSON structure.

View File

@@ -0,0 +1,94 @@
import json
from django.core.serializers.json import DjangoJSONEncoder
from django.utils import timezone
from .models import User
class UserExportService:
"""Service for exporting all user data."""
@staticmethod
def export_user_data(user: User) -> dict:
"""
Export all data associated with a user or an object containing counts/metadata and actual data.
Args:
user: The user to export data for
Returns:
dict: The complete user data export
"""
# Import models locally to avoid circular imports
from apps.parks.models import ParkReview
from apps.rides.models import RideReview
from apps.lists.models import UserList
# User account and profile
user_data = {
"username": user.username,
"email": user.email,
"date_joined": user.date_joined,
"first_name": user.first_name,
"last_name": user.last_name,
"is_active": user.is_active,
"role": user.role,
}
profile_data = {}
if hasattr(user, "profile"):
profile = user.profile
profile_data = {
"display_name": profile.display_name,
"bio": profile.bio,
"location": profile.location,
"pronouns": profile.pronouns,
"unit_system": profile.unit_system,
"social_media": {
"twitter": profile.twitter,
"instagram": profile.instagram,
"youtube": profile.youtube,
"discord": profile.discord,
},
"ride_credits": {
"coaster": profile.coaster_credits,
"dark_ride": profile.dark_ride_credits,
"flat_ride": profile.flat_ride_credits,
"water_ride": profile.water_ride_credits,
}
}
# Reviews
park_reviews = list(ParkReview.objects.filter(user=user).values(
"park__name", "rating", "review", "created_at", "updated_at", "is_published"
))
ride_reviews = list(RideReview.objects.filter(user=user).values(
"ride__name", "rating", "review", "created_at", "updated_at", "is_published"
))
# Lists
user_lists = []
for user_list in UserList.objects.filter(user=user):
items = list(user_list.items.values("order", "content_type__model", "object_id", "comment"))
user_lists.append({
"title": user_list.title,
"description": user_list.description,
"created_at": user_list.created_at,
"items": items
})
export_data = {
"account": user_data,
"profile": profile_data,
"preferences": getattr(user, "notification_preferences", {}),
"content": {
"park_reviews": park_reviews,
"ride_reviews": ride_reviews,
"lists": user_lists,
},
"export_info": {
"generated_at": timezone.now(),
"version": "1.0"
}
}
return export_data

View File

@@ -13,29 +13,11 @@ 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",
),
("django_cloudflareimages_toolkit", "0001_initial"),
]
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"},

View File

@@ -15,15 +15,12 @@ from apps.accounts.admin import (
CustomUserAdmin,
EmailVerificationAdmin,
PasswordResetAdmin,
TopListAdmin,
TopListItemAdmin,
UserProfileAdmin,
)
from apps.accounts.models import (
EmailVerification,
PasswordReset,
TopList,
TopListItem,
User,
UserProfile,
)
@@ -157,51 +154,4 @@ class TestPasswordResetAdmin(TestCase):
assert "cleanup_old_tokens" in actions
class TestTopListAdmin(TestCase):
"""Tests for TopListAdmin class."""
def setUp(self):
self.factory = RequestFactory()
self.site = AdminSite()
self.admin = TopListAdmin(model=TopList, admin_site=self.site)
def test_list_select_related(self):
"""Verify select_related for user."""
assert "user" in self.admin.list_select_related
def test_list_prefetch_related(self):
"""Verify prefetch_related for items."""
assert "items" in self.admin.list_prefetch_related
def test_publish_actions(self):
"""Verify publish actions exist."""
request = self.factory.get("/admin/")
request.user = UserModel(is_superuser=True)
actions = self.admin.get_actions(request)
assert "publish_lists" in actions
assert "unpublish_lists" in actions
class TestTopListItemAdmin(TestCase):
"""Tests for TopListItemAdmin class."""
def setUp(self):
self.factory = RequestFactory()
self.site = AdminSite()
self.admin = TopListItemAdmin(model=TopListItem, admin_site=self.site)
def test_list_select_related(self):
"""Verify select_related for top_list and user."""
assert "top_list" in self.admin.list_select_related
assert "top_list__user" in self.admin.list_select_related
assert "content_type" in self.admin.list_select_related
def test_reorder_actions(self):
"""Verify reorder actions exist."""
request = self.factory.get("/admin/")
request.user = UserModel(is_superuser=True)
actions = self.admin.get_actions(request)
assert "move_up" in actions
assert "move_down" in actions

View File

@@ -24,7 +24,7 @@ from django.utils.text import slugify
# Import all models
from apps.accounts.models import (
User, UserProfile, TopList, TopListItem, UserNotification,
User, UserProfile, UserNotification,
NotificationPreference, UserDeletionRequest
)
from apps.parks.models import (
@@ -128,7 +128,7 @@ class Command(BaseCommand):
# Create content and interactions
self.create_reviews(options['reviews'], users, parks, rides)
self.create_top_lists(users, parks, rides)
self.create_notifications(users)
self.create_moderation_data(users, parks, rides)
@@ -149,7 +149,7 @@ class Command(BaseCommand):
models_to_clear = [
# Content and interactions (clear first)
TopListItem, TopList, UserNotification, NotificationPreference,
UserNotification, NotificationPreference,
ParkReview, RideReview, ModerationAction, ModerationQueue,
# Media
@@ -1042,65 +1042,7 @@ class Command(BaseCommand):
self.stdout.write(f' ✅ Created {count} reviews')
def create_top_lists(self, users: List[User], parks: List[Park], rides: List[Ride]) -> None:
"""Create user top lists"""
self.stdout.write('📋 Creating top lists...')
if not users:
self.stdout.write(' ⚠️ No users found, skipping top lists')
return
list_count = 0
# Create top lists for some users
for user in random.sample(users, min(len(users), 10)):
# Create roller coaster top list
if rides:
coasters = [r for r in rides if r.category == 'RC']
if coasters:
top_list = TopList.objects.create(
user=user,
title=f"{user.get_display_name()}'s Top Roller Coasters",
category="RC",
description="My favorite roller coasters ranked by thrill and experience",
)
# Add items to the list
for rank, coaster in enumerate(random.sample(coasters, min(len(coasters), 10)), 1):
from django.contrib.contenttypes.models import ContentType
content_type = ContentType.objects.get_for_model(coaster)
TopListItem.objects.create(
top_list=top_list,
content_type=content_type,
object_id=coaster.pk,
rank=rank,
notes=f"Incredible {coaster.category} experience at {coaster.park.name}",
)
list_count += 1
# Create park top list
if parks and random.random() < 0.5:
top_list = TopList.objects.create(
user=user,
title=f"{user.get_display_name()}'s Favorite Parks",
category="PK",
description="Theme parks that provide the best overall experience",
)
# Add items to the list
for rank, park in enumerate(random.sample(parks, min(len(parks), 5)), 1):
from django.contrib.contenttypes.models import ContentType
content_type = ContentType.objects.get_for_model(park)
TopListItem.objects.create(
top_list=top_list,
content_type=content_type,
object_id=park.pk,
rank=rank,
notes=f"Amazing park with great {park.park_type.lower().replace('_', ' ')} atmosphere",
)
list_count += 1
self.stdout.write(f' ✅ Created {list_count} top lists')
def create_notifications(self, users: List[User]) -> None:
"""Create sample notifications for users"""
@@ -1198,7 +1140,7 @@ class Command(BaseCommand):
'Ride Models': RideModel.objects.count(),
'Park Reviews': ParkReview.objects.count(),
'Ride Reviews': RideReview.objects.count(),
'Top Lists': TopList.objects.count(),
'Notifications': UserNotification.objects.count(),
'Park Photos': ParkPhoto.objects.count(),
'Ride Photos': RidePhoto.objects.count(),

View File

@@ -1,6 +1,6 @@
from rest_framework import serializers
from drf_spectacular.utils import extend_schema_field
from apps.accounts.models import UserProfile, TopList, TopListItem
from apps.accounts.models import UserProfile
from apps.accounts.serializers import UserSerializer # existing shared user serializer
@@ -11,10 +11,21 @@ class UserProfileCreateInputSerializer(serializers.ModelSerializer):
class UserProfileUpdateInputSerializer(serializers.ModelSerializer):
cloudflare_image_id = serializers.CharField(write_only=True, required=False)
class Meta:
model = UserProfile
fields = "__all__"
extra_kwargs = {"user": {"read_only": True}}
extra_kwargs = {"user": {"read_only": True}, "avatar": {"read_only": True}}
def update(self, instance, validated_data):
cloudflare_id = validated_data.pop("cloudflare_image_id", None)
if cloudflare_id:
from django_cloudflareimages_toolkit.models import CloudflareImage
image, _ = CloudflareImage.objects.get_or_create(cloudflare_id=cloudflare_id)
instance.avatar = image
return super().update(instance, validated_data)
class UserProfileOutputSerializer(serializers.ModelSerializer):
@@ -38,49 +49,3 @@ class UserProfileOutputSerializer(serializers.ModelSerializer):
if avatar:
return getattr(avatar, "url", None)
return None
class TopListItemCreateInputSerializer(serializers.ModelSerializer):
class Meta:
model = TopListItem
fields = "__all__"
class TopListItemUpdateInputSerializer(serializers.ModelSerializer):
class Meta:
model = TopListItem
fields = "__all__"
# allow updates, adjust as needed
extra_kwargs = {"top_list": {"read_only": False}}
class TopListItemOutputSerializer(serializers.ModelSerializer):
# Remove the ride field since it doesn't exist on the model
# The model likely uses a generic foreign key or different field name
class Meta:
model = TopListItem
fields = "__all__"
class TopListCreateInputSerializer(serializers.ModelSerializer):
class Meta:
model = TopList
fields = "__all__"
class TopListUpdateInputSerializer(serializers.ModelSerializer):
class Meta:
model = TopList
fields = "__all__"
# user is set by view's perform_create
extra_kwargs = {"user": {"read_only": True}}
class TopListOutputSerializer(serializers.ModelSerializer):
user = UserSerializer(read_only=True)
items = TopListItemOutputSerializer(many=True, read_only=True)
class Meta:
model = TopList
fields = "__all__"

View File

@@ -33,6 +33,8 @@ urlpatterns = [
views.cancel_account_deletion,
name="cancel_account_deletion",
),
# Data Export endpoint
path("data-export/", views.export_user_data, name="export_user_data"),
# User profile endpoints
path("profile/", views.get_user_profile, name="get_user_profile"),
path("profile/account/", views.update_user_account, name="update_user_account"),
@@ -106,4 +108,19 @@ urlpatterns = [
path("profile/avatar/upload/", views.upload_avatar, name="upload_avatar"),
path("profile/avatar/save/", views.save_avatar_image, name="save_avatar_image"),
path("profile/avatar/delete/", views.delete_avatar, name="delete_avatar"),
# Public Profile
path("profiles/<str:username>/", views.get_public_user_profile, name="get_public_user_profile"),
]
# Register ViewSets
from rest_framework.routers import DefaultRouter
from . import views_credits
from django.urls import include
router = DefaultRouter()
router.register(r"credits", views_credits.RideCreditViewSet, basename="ride-credit")
urlpatterns += [
path("", include(router.urls)),
]

View File

@@ -8,6 +8,7 @@ preferences, privacy, notifications, and security.
from apps.api.v1.serializers.accounts import (
CompleteUserSerializer,
PublicUserSerializer,
UserPreferencesSerializer,
NotificationSettingsSerializer,
PrivacySettingsSerializer,
@@ -23,6 +24,7 @@ from apps.api.v1.serializers.accounts import (
AvatarUploadSerializer,
)
from apps.accounts.services import UserDeletionService
from apps.accounts.export_service import UserExportService
from apps.accounts.models import (
User,
UserProfile,
@@ -1583,6 +1585,57 @@ def upload_avatar(request):
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@extend_schema(
operation_id="export_user_data",
summary="Export all user data",
description="Generate a JSON dump of all user data including profile, reviews, and lists.",
responses={
200: {
"description": "User data export",
"example": {
"account": {"username": "user", "email": "user@example.com"},
"profile": {"display_name": "User"},
"content": {"park_reviews": [], "lists": []}
}
},
401: {"description": "Authentication required"},
},
tags=["Self-Service Account Management"],
)
@api_view(["GET"])
@permission_classes([IsAuthenticated])
def export_user_data(request):
"""Export all user data as JSON."""
try:
export_data = UserExportService.export_user_data(request.user)
return Response(export_data, status=status.HTTP_200_OK)
except Exception as e:
logger.error(f"Error exporting data for user {request.user.id}: {e}", exc_info=True)
return Response(
{"error": "Failed to generate data export"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
@extend_schema(
operation_id="get_public_user_profile",
summary="Get public user profile",
description="Get the public profile of a user by username.",
responses={
200: PublicUserSerializer,
404: {"description": "User not found"},
},
tags=["User Profile"],
)
@api_view(["GET"])
@permission_classes([AllowAny])
def get_public_user_profile(request, username):
"""Get public user profile by username."""
user = get_object_or_404(User, username=username)
serializer = PublicUserSerializer(user)
return Response(serializer.data, status=status.HTTP_200_OK)
# === MISSING FUNCTION IMPLEMENTATIONS ===

View File

@@ -0,0 +1,51 @@
from rest_framework import viewsets, permissions, filters
from django_filters.rest_framework import DjangoFilterBackend
from apps.rides.models.credits import RideCredit
from apps.api.v1.serializers.ride_credits import RideCreditSerializer
from drf_spectacular.utils import extend_schema, OpenApiParameter
from drf_spectacular.types import OpenApiTypes
class RideCreditViewSet(viewsets.ModelViewSet):
"""
ViewSet for managing Ride Credits.
Allows users to track rides they have ridden.
"""
serializer_class = RideCreditSerializer
permission_classes = [permissions.IsAuthenticatedOrReadOnly]
filter_backends = [DjangoFilterBackend, filters.OrderingFilter]
filterset_fields = ['user__username', 'ride__park__slug', 'ride__manufacturer__slug']
ordering_fields = ['first_ridden_at', 'last_ridden_at', 'created_at', 'count', 'rating']
ordering = ['-last_ridden_at']
def get_queryset(self):
"""
Return ride credits.
Optionally filter by user via query param ?user=username
"""
queryset = RideCredit.objects.all().select_related('ride', 'ride__park', 'user')
# Filter by user if provided
username = self.request.query_params.get('user')
if username:
queryset = queryset.filter(user__username=username)
return queryset
def perform_create(self, serializer):
"""Associate the current user with the ride credit."""
serializer.save(user=self.request.user)
@extend_schema(
summary="List ride credits",
description="List ride credits. filter by user username.",
parameters=[
OpenApiParameter(
name="user",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.STR,
description="Filter by username",
),
]
)
def list(self, request, *args, **kwargs):
return super().list(request, *args, **kwargs)

View File

@@ -37,16 +37,7 @@ def _normalize_email(value: str) -> str:
class ModelChoices:
"""Model choices utility class."""
@staticmethod
def get_top_list_categories():
"""Get top list category choices."""
return [
("RC", "Roller Coasters"),
("DR", "Dark Rides"),
("FR", "Flat Rides"),
("WR", "Water Rides"),
("PK", "Parks"),
]
# === AUTHENTICATION SERIALIZERS ===
@@ -480,129 +471,4 @@ class UserProfileUpdateInputSerializer(serializers.Serializer):
water_ride_credits = serializers.IntegerField(required=False)
# === TOP LIST SERIALIZERS ===
@extend_schema_serializer(
examples=[
OpenApiExample(
"Top List Example",
summary="Example top list response",
description="A user's top list of rides or parks",
value={
"id": 1,
"title": "My Top 10 Roller Coasters",
"category": "RC",
"description": "My favorite roller coasters ranked",
"user": {"username": "coaster_fan", "display_name": "Coaster Fan"},
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-08-15T12:00:00Z",
},
)
]
)
class TopListOutputSerializer(serializers.Serializer):
"""Output serializer for top lists."""
id = serializers.IntegerField()
title = serializers.CharField()
category = serializers.CharField()
description = serializers.CharField()
created_at = serializers.DateTimeField()
updated_at = serializers.DateTimeField()
# User info
user = serializers.SerializerMethodField()
@extend_schema_field(serializers.DictField())
def get_user(self, obj) -> Dict[str, Any]:
return {
"username": obj.user.username,
"display_name": obj.user.get_display_name(),
}
class TopListCreateInputSerializer(serializers.Serializer):
"""Input serializer for creating top lists."""
title = serializers.CharField(max_length=100)
category = serializers.ChoiceField(choices=ModelChoices.get_top_list_categories())
description = serializers.CharField(allow_blank=True, default="")
class TopListUpdateInputSerializer(serializers.Serializer):
"""Input serializer for updating top lists."""
title = serializers.CharField(max_length=100, required=False)
category = serializers.ChoiceField(
choices=ModelChoices.get_top_list_categories(), required=False
)
description = serializers.CharField(allow_blank=True, required=False)
# === TOP LIST ITEM SERIALIZERS ===
@extend_schema_serializer(
examples=[
OpenApiExample(
"Top List Item Example",
summary="Example top list item response",
description="An item in a user's top list",
value={
"id": 1,
"rank": 1,
"notes": "Amazing airtime and smooth ride",
"object_name": "Steel Vengeance",
"object_type": "Ride",
"top_list": {"id": 1, "title": "My Top 10 Roller Coasters"},
},
)
]
)
class TopListItemOutputSerializer(serializers.Serializer):
"""Output serializer for top list items."""
id = serializers.IntegerField()
rank = serializers.IntegerField()
notes = serializers.CharField()
object_name = serializers.SerializerMethodField()
object_type = serializers.SerializerMethodField()
# Top list info
top_list = serializers.SerializerMethodField()
@extend_schema_field(serializers.CharField())
def get_object_name(self, obj) -> str:
"""Get the name of the referenced object."""
# This would need to be implemented based on the generic foreign key
return "Object Name" # Placeholder
@extend_schema_field(serializers.CharField())
def get_object_type(self, obj) -> str:
"""Get the type of the referenced object."""
return obj.content_type.model_class().__name__
@extend_schema_field(serializers.DictField())
def get_top_list(self, obj) -> Dict[str, Any]:
return {
"id": obj.top_list.id,
"title": obj.top_list.title,
}
class TopListItemCreateInputSerializer(serializers.Serializer):
"""Input serializer for creating top list items."""
top_list_id = serializers.IntegerField()
content_type_id = serializers.IntegerField()
object_id = serializers.IntegerField()
rank = serializers.IntegerField(min_value=1)
notes = serializers.CharField(allow_blank=True, default="")
class TopListItemUpdateInputSerializer(serializers.Serializer):
"""Input serializer for updating top list items."""
rank = serializers.IntegerField(min_value=1, required=False)
notes = serializers.CharField(allow_blank=True, required=False)

View File

@@ -0,0 +1,6 @@
from django.urls import path
from .views import GenerateUploadURLView
urlpatterns = [
path("generate-upload-url/", GenerateUploadURLView.as_view(), name="generate-upload-url"),
]

View File

@@ -0,0 +1,37 @@
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated
from rest_framework import status
from apps.core.utils.cloudflare import get_direct_upload_url
from django.core.exceptions import ImproperlyConfigured
import requests
import logging
logger = logging.getLogger(__name__)
class GenerateUploadURLView(APIView):
permission_classes = [IsAuthenticated]
def post(self, request):
try:
# Pass user_id for metadata if needed
result = get_direct_upload_url(user_id=str(request.user.id))
return Response(result, status=status.HTTP_200_OK)
except ImproperlyConfigured as e:
logger.error(f"Configuration Error: {e}")
return Response(
{"detail": "Server configuration error."},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
except requests.RequestException as e:
logger.error(f"Cloudflare API Error: {e}")
return Response(
{"detail": "Failed to generate upload URL."},
status=status.HTTP_502_BAD_GATEWAY
)
except Exception as e:
logger.exception("Unexpected error generating upload URL")
return Response(
{"detail": "An unexpected error occurred."},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)

View File

@@ -0,0 +1,88 @@
"""
Park history API views.
"""
from rest_framework import viewsets, mixins
from rest_framework.response import Response
from rest_framework.permissions import AllowAny
from django.shortcuts import get_object_or_404
from drf_spectacular.utils import extend_schema
from apps.parks.models import Park
from apps.rides.models import Ride
from apps.api.v1.serializers.history import ParkHistoryOutputSerializer, RideHistoryOutputSerializer
class ParkHistoryViewSet(viewsets.GenericViewSet):
"""
ViewSet for retrieving park history.
"""
permission_classes = [AllowAny]
lookup_field = "slug"
lookup_url_kwarg = "park_slug"
@extend_schema(
summary="Get park history",
description="Retrieve history events for a park.",
responses={200: ParkHistoryOutputSerializer},
tags=["Park History"],
)
def list(self, request, park_slug=None):
park = get_object_or_404(Park, slug=park_slug)
events = []
if hasattr(park, "events"):
events = park.events.all().order_by("-pgh_created_at")
summary = {
"total_events": len(events),
"first_recorded": events.last().pgh_created_at if len(events) else None,
"last_modified": events.first().pgh_created_at if len(events) else None,
}
data = {
"park": park,
"current_state": park,
"summary": summary,
"events": events
}
serializer = ParkHistoryOutputSerializer(data)
return Response(serializer.data)
class RideHistoryViewSet(viewsets.GenericViewSet):
"""
ViewSet for retrieving ride history.
"""
permission_classes = [AllowAny]
lookup_field = "slug"
lookup_url_kwarg = "ride_slug"
@extend_schema(
summary="Get ride history",
description="Retrieve history events for a ride.",
responses={200: RideHistoryOutputSerializer},
tags=["Ride History"],
)
def list(self, request, park_slug=None, ride_slug=None):
park = get_object_or_404(Park, slug=park_slug)
ride = get_object_or_404(Ride, slug=ride_slug, park=park)
events = []
if hasattr(ride, "events"):
events = ride.events.all().order_by("-pgh_created_at")
summary = {
"total_events": len(events),
"first_recorded": events.last().pgh_created_at if len(events) else None,
"last_modified": events.first().pgh_created_at if len(events) else None,
}
data = {
"ride": ride,
"current_state": ride,
"summary": summary,
"events": events
}
serializer = RideHistoryOutputSerializer(data)
return Response(serializer.data)

View File

@@ -0,0 +1,162 @@
"""
Park review API views for ThrillWiki API v1.
This module contains park review ViewSet following the reviews pattern.
Provides CRUD operations for park reviews nested under parks/{slug}/reviews/
"""
import logging
from django.core.exceptions import PermissionDenied
from django.db.models import Avg
from django.utils import timezone
from drf_spectacular.utils import extend_schema_view, extend_schema
from drf_spectacular.types import OpenApiTypes
from rest_framework import status
from rest_framework.decorators import action
from rest_framework.exceptions import ValidationError, NotFound
from rest_framework.permissions import IsAuthenticated, AllowAny
from rest_framework.response import Response
from rest_framework.viewsets import ModelViewSet
from apps.parks.models import Park, ParkReview
from apps.api.v1.serializers.park_reviews import (
ParkReviewOutputSerializer,
ParkReviewCreateInputSerializer,
ParkReviewUpdateInputSerializer,
ParkReviewListOutputSerializer,
ParkReviewStatsOutputSerializer,
ParkReviewModerationInputSerializer,
)
logger = logging.getLogger(__name__)
@extend_schema_view(
list=extend_schema(
summary="List park reviews",
tags=["Park Reviews"],
),
create=extend_schema(
summary="Create park review",
tags=["Park Reviews"],
),
retrieve=extend_schema(
summary="Get park review details",
tags=["Park Reviews"],
),
update=extend_schema(
summary="Update park review",
tags=["Park Reviews"],
),
partial_update=extend_schema(
summary="Partially update park review",
tags=["Park Reviews"],
),
destroy=extend_schema(
summary="Delete park review",
tags=["Park Reviews"],
),
)
class ParkReviewViewSet(ModelViewSet):
"""
ViewSet for managing park reviews with full CRUD operations.
"""
lookup_field = "id"
def get_permissions(self):
"""Set permissions based on action."""
if self.action in ['list', 'retrieve', 'stats']:
permission_classes = [AllowAny]
else:
permission_classes = [IsAuthenticated]
return [permission() for permission in permission_classes]
def get_queryset(self):
"""Get reviews for the current park."""
queryset = ParkReview.objects.select_related(
"park", "user", "user__profile"
)
park_slug = self.kwargs.get("park_slug")
if park_slug:
try:
park, _ = Park.get_by_slug(park_slug)
queryset = queryset.filter(park=park)
except Park.DoesNotExist:
return queryset.none()
if not (hasattr(self.request, 'user') and getattr(self.request.user, 'is_staff', False)):
queryset = queryset.filter(is_published=True)
return queryset.order_by("-created_at")
def get_serializer_class(self):
if self.action == "list":
return ParkReviewListOutputSerializer
elif self.action == "create":
return ParkReviewCreateInputSerializer
elif self.action in ["update", "partial_update"]:
return ParkReviewUpdateInputSerializer
else:
return ParkReviewOutputSerializer
def perform_create(self, serializer):
park_slug = self.kwargs.get("park_slug")
try:
park, _ = Park.get_by_slug(park_slug)
except Park.DoesNotExist:
raise NotFound("Park not found")
if ParkReview.objects.filter(park=park, user=self.request.user).exists():
raise ValidationError("You have already reviewed this park")
serializer.save(
park=park,
user=self.request.user,
is_published=True
)
def perform_update(self, serializer):
instance = self.get_object()
if not (self.request.user == instance.user or getattr(self.request.user, "is_staff", False)):
raise PermissionDenied("You can only edit your own reviews.")
serializer.save()
def perform_destroy(self, instance):
if not (self.request.user == instance.user or getattr(self.request.user, "is_staff", False)):
raise PermissionDenied("You can only delete your own reviews.")
instance.delete()
@extend_schema(
summary="Get park review statistics",
responses={200: ParkReviewStatsOutputSerializer},
tags=["Park Reviews"],
)
@action(detail=False, methods=["get"])
def stats(self, request, park_slug=None):
try:
park, _ = Park.get_by_slug(park_slug)
except Park.DoesNotExist:
return Response({"error": "Park not found"}, status=status.HTTP_404_NOT_FOUND)
reviews = ParkReview.objects.filter(park=park, is_published=True)
total_reviews = reviews.count()
avg_rating = reviews.aggregate(avg=Avg('rating'))['avg']
rating_distribution = {}
for i in range(1, 11):
rating_distribution[str(i)] = reviews.filter(rating=i).count()
from datetime import timedelta
recent_reviews = reviews.filter(created_at__gte=timezone.now() - timedelta(days=30)).count()
stats = {
"total_reviews": total_reviews,
"published_reviews": total_reviews,
"pending_reviews": ParkReview.objects.filter(park=park, is_published=False).count(),
"average_rating": avg_rating,
"rating_distribution": rating_distribution,
"recent_reviews": recent_reviews,
}
return Response(ParkReviewStatsOutputSerializer(stats).data)

View File

@@ -42,10 +42,19 @@ router.register(r"", ParkPhotoViewSet, basename="park-photo")
# Create routers for nested ride endpoints
ride_photos_router = DefaultRouter()
ride_photos_router.register(r"", RidePhotoViewSet, basename="ride-photo")
from .ride_reviews_views import RideReviewViewSet
ride_reviews_router = DefaultRouter()
ride_reviews_router.register(r"", RideReviewViewSet, basename="ride-review")
from .park_reviews_views import ParkReviewViewSet
from .history_views import ParkHistoryViewSet, RideHistoryViewSet
# Create routers for nested park endpoints
reviews_router = DefaultRouter()
reviews_router.register(r"", ParkReviewViewSet, basename="park-review")
app_name = "api_v1_parks"
urlpatterns = [
@@ -86,7 +95,7 @@ urlpatterns = [
name="park-image-settings",
),
# Park photo endpoints - domain-specific photo management
path("<int:park_pk>/photos/", include(router.urls)),
path("<str:park_pk>/photos/", include(router.urls)),
# Nested ride photo endpoints - photos for specific rides within parks
path("<str:park_slug>/rides/<str:ride_slug>/photos/", include(ride_photos_router.urls)),
@@ -95,6 +104,15 @@ urlpatterns = [
path("<str:park_slug>/rides/<str:ride_slug>/reviews/", include(ride_reviews_router.urls)),
# Nested ride review endpoints - reviews for specific rides within parks
path("<str:park_slug>/rides/<str:ride_slug>/reviews/", include(ride_reviews_router.urls)),
# Ride History
path("<str:park_slug>/rides/<str:ride_slug>/history/", RideHistoryViewSet.as_view({'get': 'list'}), name="ride-history"),
# Park Reviews
path("<str:park_slug>/reviews/", include(reviews_router.urls)),
# Park History
path("<str:park_slug>/history/", ParkHistoryViewSet.as_view({'get': 'list'}), name="park-history"),
# Roadtrip API endpoints
path("roadtrip/create/", CreateTripView.as_view(), name="roadtrip-create"),

View File

@@ -142,10 +142,14 @@ class ParkPhotoViewSet(ModelViewSet):
"park", "park__operator", "uploaded_by"
)
# If park_pk is provided in URL kwargs, filter by park
# If park_pk is provided in URL kwargs, filter by park
park_pk = self.kwargs.get("park_pk")
if park_pk:
queryset = queryset.filter(park_id=park_pk)
if str(park_pk).isdigit():
queryset = queryset.filter(park_id=park_pk)
else:
queryset = queryset.filter(park__slug=park_pk)
return queryset.order_by("-created_at")
@@ -164,10 +168,16 @@ class ParkPhotoViewSet(ModelViewSet):
"""Create a new park photo using ParkMediaService."""
park_id = self.kwargs.get("park_pk")
if not park_id:
raise ValidationError("Park ID is required")
raise ValidationError("Park ID/Slug is required")
try:
Park.objects.get(pk=park_id)
if str(park_id).isdigit():
park = Park.objects.get(pk=park_id)
else:
park = Park.objects.get(slug=park_id)
# Use real park ID
park_id = park.id
except Park.DoesNotExist:
raise ValidationError("Park not found")
@@ -342,7 +352,10 @@ class ParkPhotoViewSet(ModelViewSet):
# Filter photos to only those belonging to this park (if park_pk provided)
photos_queryset = ParkPhoto.objects.filter(id__in=photo_ids)
if park_id:
photos_queryset = photos_queryset.filter(park_id=park_id)
if str(park_id).isdigit():
photos_queryset = photos_queryset.filter(park_id=park_id)
else:
photos_queryset = photos_queryset.filter(park__slug=park_id)
updated_count = photos_queryset.update(is_approved=approve)
@@ -385,10 +398,13 @@ class ParkPhotoViewSet(ModelViewSet):
park = None
if park_pk:
try:
park = Park.objects.get(pk=park_pk)
if str(park_pk).isdigit():
park = Park.objects.get(pk=park_pk)
else:
park = Park.objects.get(slug=park_pk)
except Park.DoesNotExist:
return ErrorHandler.handle_api_error(
NotFoundError(f"Park with id {park_pk} not found"),
NotFoundError(f"Park with id/slug {park_pk} not found"),
user_message="Park not found",
status_code=status.HTTP_404_NOT_FOUND,
)
@@ -474,7 +490,10 @@ class ParkPhotoViewSet(ModelViewSet):
)
try:
park = Park.objects.get(pk=park_pk)
if str(park_pk).isdigit():
park = Park.objects.get(pk=park_pk)
else:
park = Park.objects.get(slug=park_pk)
except Park.DoesNotExist:
return Response(
{"error": "Park not found"},

View File

@@ -30,12 +30,7 @@ AuthStatusOutputSerializer: Any = None
UserProfileCreateInputSerializer: Any = None
UserProfileUpdateInputSerializer: Any = None
UserProfileOutputSerializer: Any = None
TopListCreateInputSerializer: Any = None
TopListUpdateInputSerializer: Any = None
TopListOutputSerializer: Any = None
TopListItemCreateInputSerializer: Any = None
TopListItemUpdateInputSerializer: Any = None
TopListItemOutputSerializer: Any = None
# Explicit __all__ for static analysis — update this list if new serializers are added.
__all__ = (
@@ -54,10 +49,5 @@ __all__ = (
"UserProfileCreateInputSerializer",
"UserProfileUpdateInputSerializer",
"UserProfileOutputSerializer",
"TopListCreateInputSerializer",
"TopListUpdateInputSerializer",
"TopListOutputSerializer",
"TopListItemCreateInputSerializer",
"TopListItemUpdateInputSerializer",
"TopListItemOutputSerializer",
)

View File

@@ -90,12 +90,7 @@ _ACCOUNTS_SYMBOLS: List[str] = [
"UserProfileOutputSerializer",
"UserProfileCreateInputSerializer",
"UserProfileUpdateInputSerializer",
"TopListOutputSerializer",
"TopListCreateInputSerializer",
"TopListUpdateInputSerializer",
"TopListItemOutputSerializer",
"TopListItemCreateInputSerializer",
"TopListItemUpdateInputSerializer",
"UserOutputSerializer",
"LoginInputSerializer",
"LoginOutputSerializer",

View File

@@ -18,6 +18,7 @@ from apps.accounts.models import (
NotificationPreference,
)
from apps.lists.models import UserList
from apps.rides.models.credits import RideCredit
from apps.core.choices.serializers import RichChoiceFieldSerializer
UserModel = get_user_model()
@@ -66,6 +67,8 @@ class UserProfileSerializer(serializers.ModelSerializer):
avatar_url = serializers.SerializerMethodField()
avatar_variants = serializers.SerializerMethodField()
total_credits = serializers.SerializerMethodField()
unique_parks = serializers.SerializerMethodField()
class Meta:
model = UserProfile
@@ -87,8 +90,19 @@ class UserProfileSerializer(serializers.ModelSerializer):
"water_ride_credits",
"unit_system",
"location",
"total_credits",
"unique_parks",
]
read_only_fields = ["profile_id", "avatar_url", "avatar_variants"]
read_only_fields = ["profile_id", "avatar_url", "avatar_variants", "total_credits", "unique_parks"]
def get_total_credits(self, obj):
"""Get the total number of ride credits."""
return RideCredit.objects.filter(user=obj.user).count()
def get_unique_parks(self, obj):
"""Get the number of unique parks visited."""
# This assumes RideCredit -> Ride -> Park relationship
return RideCredit.objects.filter(user=obj.user).values("ride__park").distinct().count()
def get_avatar_url(self, obj):
"""Get the avatar URL with fallback to default letter-based avatar."""
@@ -167,6 +181,25 @@ class CompleteUserSerializer(serializers.ModelSerializer):
read_only_fields = ["user_id", "date_joined", "role"]
class PublicUserSerializer(serializers.ModelSerializer):
"""
Public user serializer for viewing other users' profiles.
Only exposes public information.
"""
profile = UserProfileSerializer(read_only=True)
class Meta:
model = User
fields = [
"user_id",
"username",
"date_joined",
"role",
"profile",
]
read_only_fields = fields
# === USER SETTINGS SERIALIZERS ===

View File

@@ -0,0 +1,171 @@
"""
Serializers for park review API endpoints.
This module contains serializers for park review CRUD operations.
"""
from rest_framework import serializers
from drf_spectacular.utils import extend_schema_field, extend_schema_serializer, OpenApiExample
from apps.parks.models.reviews import ParkReview
from apps.api.v1.serializers.reviews import ReviewUserSerializer
@extend_schema_serializer(
examples=[
OpenApiExample(
name="Complete Park Review",
summary="Full park review response",
description="Example response showing all fields for a park review",
value={
"id": 123,
"title": "Great family park!",
"content": "We had a wonderful time. The atmosphere is charming.",
"rating": 9,
"visit_date": "2023-06-15",
"created_at": "2023-06-16T10:30:00Z",
"updated_at": "2023-06-16T10:30:00Z",
"is_published": True,
"user": {
"username": "park_fan",
"display_name": "Park Fan",
"avatar_url": "https://example.com/avatar.jpg"
},
"park": {
"id": 101,
"name": "Cedar Point",
"slug": "cedar-point"
}
}
)
]
)
class ParkReviewOutputSerializer(serializers.ModelSerializer):
"""Output serializer for park reviews."""
user = ReviewUserSerializer(read_only=True)
park = serializers.SerializerMethodField()
class Meta:
model = ParkReview
fields = [
"id",
"title",
"content",
"rating",
"visit_date",
"created_at",
"updated_at",
"is_published",
"user",
"park",
]
read_only_fields = [
"id",
"created_at",
"updated_at",
"user",
"park",
]
@extend_schema_field(serializers.DictField())
def get_park(self, obj):
"""Get park information."""
return {
"id": obj.park.id,
"name": obj.park.name,
"slug": obj.park.slug,
}
class ParkReviewCreateInputSerializer(serializers.ModelSerializer):
"""Input serializer for creating park reviews."""
class Meta:
model = ParkReview
fields = [
"title",
"content",
"rating",
"visit_date",
]
def validate_rating(self, value):
"""Validate rating is between 1 and 10."""
if not (1 <= value <= 10):
raise serializers.ValidationError("Rating must be between 1 and 10.")
return value
class ParkReviewUpdateInputSerializer(serializers.ModelSerializer):
"""Input serializer for updating park reviews."""
class Meta:
model = ParkReview
fields = [
"title",
"content",
"rating",
"visit_date",
]
def validate_rating(self, value):
"""Validate rating is between 1 and 10."""
if not (1 <= value <= 10):
raise serializers.ValidationError("Rating must be between 1 and 10.")
return value
class ParkReviewListOutputSerializer(serializers.ModelSerializer):
"""Simplified output serializer for park review lists."""
user = ReviewUserSerializer(read_only=True)
park_name = serializers.CharField(source="park.name", read_only=True)
class Meta:
model = ParkReview
fields = [
"id",
"title",
"rating",
"visit_date",
"created_at",
"is_published",
"user",
"park_name",
]
read_only_fields = fields
class ParkReviewStatsOutputSerializer(serializers.Serializer):
"""Output serializer for park review statistics."""
total_reviews = serializers.IntegerField()
published_reviews = serializers.IntegerField()
pending_reviews = serializers.IntegerField()
average_rating = serializers.FloatField(allow_null=True)
rating_distribution = serializers.DictField(
child=serializers.IntegerField(),
help_text="Count of reviews by rating (1-10)"
)
recent_reviews = serializers.IntegerField()
class ParkReviewModerationInputSerializer(serializers.Serializer):
"""Input serializer for review moderation operations."""
review_ids = serializers.ListField(
child=serializers.IntegerField(),
help_text="List of review IDs to moderate"
)
action = serializers.ChoiceField(
choices=[
("publish", "Publish"),
("unpublish", "Unpublish"),
("delete", "Delete"),
],
help_text="Moderation action to perform"
)
moderation_notes = serializers.CharField(
required=False,
allow_blank=True,
help_text="Optional notes about the moderation action"
)

View File

@@ -0,0 +1,47 @@
from rest_framework import serializers
from drf_spectacular.utils import extend_schema_field
from apps.rides.models.credits import RideCredit
from apps.rides.models import Ride
from apps.api.v1.serializers.rides import RideListOutputSerializer
class RideCreditSerializer(serializers.ModelSerializer):
"""Serializer for user ride credits."""
ride_id = serializers.PrimaryKeyRelatedField(
queryset=Ride.objects.all(), source='ride', write_only=True
)
ride = RideListOutputSerializer(read_only=True)
class Meta:
model = RideCredit
fields = [
'id',
'ride',
'ride_id',
'count',
'rating',
'first_ridden_at',
'last_ridden_at',
'notes',
'created_at',
'updated_at',
]
read_only_fields = ['id', 'created_at', 'updated_at']
def validate(self, attrs):
"""
Validate data.
"""
# Ensure dates make sense
first = attrs.get('first_ridden_at')
last = attrs.get('last_ridden_at')
if first and last and last < first:
raise serializers.ValidationError("Last ridden date cannot be before first ridden date.")
return attrs
def create(self, validated_data):
"""Create a new ride credit."""
user = self.context['request'].user
validated_data['user'] = user
return super().create(validated_data)

View File

@@ -16,6 +16,7 @@ from .views import (
NewContentAPIView,
TriggerTrendingCalculationAPIView,
)
from .views.discovery import DiscoveryAPIView
from .views.stats import StatsAPIView, StatsRecalculateAPIView
from .views.reviews import LatestReviewsAPIView
from django.urls import path, include
@@ -44,6 +45,7 @@ urlpatterns = [
),
# Trending system endpoints
path("trending/", TrendingAPIView.as_view(), name="trending"),
path("discovery/", DiscoveryAPIView.as_view(), name="discovery"),
path("new-content/", NewContentAPIView.as_view(), name="new-content"),
path(
"trending/calculate/",
@@ -75,6 +77,11 @@ urlpatterns = [
path("maps/", include("apps.api.v1.maps.urls")),
path("lists/", include("apps.lists.urls")),
path("moderation/", include("apps.moderation.urls")),
path("reviews/", include("apps.reviews.urls")),
path("media/", include("apps.media.urls")),
path("blog/", include("apps.blog.urls")),
path("support/", include("apps.support.urls")),
path("images/", include("apps.api.v1.images.urls")),
# Cloudflare Images Toolkit API endpoints
path("cloudflare-images/", include("django_cloudflareimages_toolkit.urls")),
# Include router URLs (for rankings and any other router-registered endpoints)

View File

@@ -0,0 +1,96 @@
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status
from rest_framework.permissions import AllowAny
from django.db.models import F
from django.utils import timezone
from drf_spectacular.utils import extend_schema
from datetime import timedelta
from apps.parks.models import Park
from apps.rides.models import Ride
class DiscoveryAPIView(APIView):
"""
API endpoint for discovery content (Top Lists, Opening/Closing Soon).
"""
permission_classes = [AllowAny]
@extend_schema(
summary="Get discovery content",
description="Retrieve curated lists for discovery tabs (Top, Opening, Closing).",
responses={200: "object"},
tags=["Discovery"],
)
def get(self, request):
today = timezone.now().date()
limit = 10
# --- TOP LISTS ---
# Top Parks by average rating
top_parks = Park.objects.filter(average_rating__isnull=False).order_by("-average_rating")[:limit]
# Top Rides by average rating (fallback to RideRanking in future)
top_rides = Ride.objects.filter(average_rating__isnull=False).order_by("-average_rating")[:limit]
# --- OPENING ---
# Opening Soon (Future opening date)
opening_soon_parks = Park.objects.filter(opening_date__gt=today).order_by("opening_date")[:limit]
opening_soon_rides = Ride.objects.filter(opening_date__gt=today).order_by("opening_date")[:limit]
# Recently Opened (Past opening date, descending)
recently_opened_parks = Park.objects.filter(opening_date__lte=today).order_by("-opening_date")[:limit]
recently_opened_rides = Ride.objects.filter(opening_date__lte=today).order_by("-opening_date")[:limit]
# --- CLOSING ---
# Closing Soon (Future closing date)
closing_soon_parks = Park.objects.filter(closing_date__gt=today).order_by("closing_date")[:limit]
closing_soon_rides = Ride.objects.filter(closing_date__gt=today).order_by("closing_date")[:limit]
# Recently Closed (Past closing date, descending)
recently_closed_parks = Park.objects.filter(closing_date__lte=today).order_by("-closing_date")[:limit]
recently_closed_rides = Ride.objects.filter(closing_date__lte=today).order_by("-closing_date")[:limit]
data = {
"top_parks": self._serialize(top_parks, "park"),
"top_rides": self._serialize(top_rides, "ride"),
"opening_soon": {
"parks": self._serialize(opening_soon_parks, "park"),
"rides": self._serialize(opening_soon_rides, "ride"),
},
"recently_opened": {
"parks": self._serialize(recently_opened_parks, "park"),
"rides": self._serialize(recently_opened_rides, "ride"),
},
"closing_soon": {
"parks": self._serialize(closing_soon_parks, "park"),
"rides": self._serialize(closing_soon_rides, "ride"),
},
"recently_closed": {
"parks": self._serialize(recently_closed_parks, "park"),
"rides": self._serialize(recently_closed_rides, "ride"),
}
}
return Response(data)
def _serialize(self, queryset, type_):
results = []
for item in queryset:
data = {
"id": item.id,
"name": item.name,
"slug": item.slug,
"average_rating": item.average_rating,
}
if type_ == "park":
data.update({
"city": item.location.city if item.location else None,
"state": item.location.state if item.location else None,
})
elif type_ == "ride":
data.update({
"park_name": item.park.name,
"park_slug": item.park.slug
})
results.append(data)
return results

View File

View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class BlogConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.blog"
verbose_name = "Blog"

View File

@@ -0,0 +1,69 @@
# Generated by Django 5.1.6 on 2025-12-26 14:32
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("django_cloudflareimages_toolkit", "0001_initial"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="Tag",
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)),
("slug", models.SlugField(help_text="URL-friendly identifier", max_length=200, unique=True)),
("name", models.CharField(max_length=50, unique=True)),
],
options={
"ordering": ["name"],
},
),
migrations.CreateModel(
name="Post",
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)),
("name", models.CharField(help_text="Name of the object", max_length=200)),
("slug", models.SlugField(help_text="URL-friendly identifier", max_length=200, unique=True)),
("title", models.CharField(max_length=255)),
("content", models.TextField(help_text="Markdown content supported")),
("excerpt", models.TextField(blank=True, help_text="Short summary for lists")),
("published_at", models.DateTimeField(blank=True, db_index=True, null=True)),
("is_published", models.BooleanField(db_index=True, default=False)),
(
"author",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="blog_posts",
to=settings.AUTH_USER_MODEL,
),
),
(
"image",
models.ForeignKey(
blank=True,
help_text="Featured image",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="blog_posts",
to="django_cloudflareimages_toolkit.cloudflareimage",
),
),
("tags", models.ManyToManyField(blank=True, related_name="posts", to="blog.tag")),
],
options={
"ordering": ["-published_at", "-created_at"],
},
),
]

View File

View File

@@ -0,0 +1,45 @@
from django.db import models
from django.conf import settings
from apps.core.models import SluggedModel
# Using string reference for CloudflareImage
from django_cloudflareimages_toolkit.models import CloudflareImage
class Tag(SluggedModel):
name = models.CharField(max_length=50, unique=True)
class Meta:
ordering = ["name"]
def __str__(self):
return self.name
class Post(SluggedModel):
title = models.CharField(max_length=255)
content = models.TextField(help_text="Markdown content supported")
excerpt = models.TextField(blank=True, help_text="Short summary for lists")
image = models.ForeignKey(
CloudflareImage,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="blog_posts",
help_text="Featured image"
)
author = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="blog_posts"
)
published_at = models.DateTimeField(null=True, blank=True, db_index=True)
is_published = models.BooleanField(default=False, db_index=True)
tags = models.ManyToManyField(Tag, blank=True, related_name="posts")
class Meta:
ordering = ["-published_at", "-created_at"]
def __str__(self):
return self.title

View File

@@ -0,0 +1,60 @@
from rest_framework import serializers
from .models import Post, Tag
from apps.accounts.serializers import UserSerializer
from apps.media.serializers import CloudflareImageSerializer
from django_cloudflareimages_toolkit.models import CloudflareImage
class TagSerializer(serializers.ModelSerializer):
class Meta:
model = Tag
fields = ["id", "name", "slug"]
class PostListSerializer(serializers.ModelSerializer):
"""Lighter serializer for lists"""
author = UserSerializer(read_only=True)
tags = TagSerializer(many=True, read_only=True)
image = CloudflareImageSerializer(read_only=True)
class Meta:
model = Post
fields = [
"id",
"title",
"slug",
"excerpt",
"image",
"author",
"published_at",
"tags",
]
class PostDetailSerializer(serializers.ModelSerializer):
author = UserSerializer(read_only=True)
tags = TagSerializer(many=True, read_only=True)
image = CloudflareImageSerializer(read_only=True)
image_id = serializers.PrimaryKeyRelatedField(
queryset=CloudflareImage.objects.all(),
source='image',
write_only=True,
required=False,
allow_null=True
)
class Meta:
model = Post
fields = [
"id",
"title",
"slug",
"content",
"excerpt",
"image",
"image_id",
"author",
"published_at",
"is_published",
"tags",
"created_at",
"updated_at",
]
read_only_fields = ["id", "slug", "created_at", "updated_at", "author"]

11
backend/apps/blog/urls.py Normal file
View File

@@ -0,0 +1,11 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import PostViewSet, TagViewSet
router = DefaultRouter()
router.register(r"posts", PostViewSet, basename="post")
router.register(r"tags", TagViewSet, basename="tag")
urlpatterns = [
path("", include(router.urls)),
]

View File

@@ -0,0 +1,42 @@
from rest_framework import viewsets, permissions, filters
from django_filters.rest_framework import DjangoFilterBackend
from django.utils import timezone
from .models import Post, Tag
from .serializers import PostListSerializer, PostDetailSerializer, TagSerializer
from apps.core.permissions import IsStaffOrReadOnly
class TagViewSet(viewsets.ReadOnlyModelViewSet):
queryset = Tag.objects.all()
serializer_class = TagSerializer
permission_classes = [permissions.AllowAny]
filter_backends = [filters.SearchFilter]
search_fields = ["name"]
pagination_class = None # Tags are usually few
class PostViewSet(viewsets.ModelViewSet):
"""
Public API: Read Only (unless staff).
Only published posts unless staff.
"""
permission_classes = [IsStaffOrReadOnly]
filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
search_fields = ["title", "excerpt", "content"]
filterset_fields = ["tags__slug", "is_published"]
ordering_fields = ["published_at", "created_at"]
ordering = ["-published_at"]
lookup_field = "slug"
def get_queryset(self):
qs = Post.objects.all()
# If not staff, filter only published and past posts
if not self.request.user.is_staff:
qs = qs.filter(is_published=True, published_at__lte=timezone.now())
return qs.select_related("author", "image").prefetch_related("tags")
def get_serializer_class(self):
if self.action == "list":
return PostListSerializer
return PostDetailSerializer
def perform_create(self, serializer):
serializer.save(author=self.request.user)

View File

@@ -11,6 +11,7 @@ from django.http import HttpRequest, HttpResponseBase
from django.utils.decorators import method_decorator
from django.views.decorators.vary import vary_on_headers
from django.views import View
from rest_framework.response import Response as DRFResponse
from apps.core.services.enhanced_cache_service import EnhancedCacheService
import logging
@@ -81,6 +82,14 @@ def cache_api_response(
"cache_hit": True,
},
)
# If cached data is our dict format for DRF responses, reconstruct it
if isinstance(cached_response, dict) and '__drf_data__' in cached_response:
return DRFResponse(
data=cached_response['__drf_data__'],
status=cached_response.get('status', 200)
)
return cached_response
# Execute view and cache result
@@ -90,8 +99,18 @@ def cache_api_response(
# Only cache successful responses
if hasattr(response, "status_code") and response.status_code == 200:
# For DRF responses, we must cache the data, not the response object
# because the response object is not rendered yet and cannot be pickled
if hasattr(response, 'data'):
cache_payload = {
'__drf_data__': response.data,
'status': response.status_code
}
else:
cache_payload = response
getattr(cache_service, cache_backend + "_cache").set(
cache_key, response, timeout
cache_key, cache_payload, timeout
)
logger.debug(
f"Cached API response for view {view_func.__name__}",

View File

@@ -16,3 +16,13 @@ class IsOwnerOrReadOnly(permissions.BasePermission):
if hasattr(obj, 'user'):
return obj.user == request.user
return False
class IsStaffOrReadOnly(permissions.BasePermission):
"""
Custom permission to only allow staff to edit it.
"""
def has_permission(self, request, view):
if request.method in permissions.SAFE_METHODS:
return True
return request.user and request.user.is_staff

View File

@@ -229,7 +229,6 @@ class EntityFuzzyMatcher:
parks = Park.objects.filter(
Q(name__icontains=query)
| Q(slug__icontains=query.lower().replace(" ", "-"))
| Q(former_names__icontains=query)
)[: self.MAX_CANDIDATES]
for park in parks:
@@ -249,7 +248,6 @@ class EntityFuzzyMatcher:
rides = Ride.objects.select_related("park").filter(
Q(name__icontains=query)
| Q(slug__icontains=query.lower().replace(" ", "-"))
| Q(former_names__icontains=query)
| Q(park__name__icontains=query)
)[: self.MAX_CANDIDATES]

View File

@@ -0,0 +1,54 @@
import pytest
from django.contrib.auth import get_user_model
from apps.parks.models import Park, Company
import pghistory
User = get_user_model()
@pytest.mark.django_db
class TestTrackedModel:
"""
Tests for the TrackedModel base class and pghistory integration.
"""
def test_create_history_tracking(self):
"""Test that creating a model instance creates a history event."""
user = User.objects.create_user(username="testuser", password="password")
company = Company.objects.create(name="Test Operator", roles=["OPERATOR"])
with pghistory.context(user=user.id):
park = Park.objects.create(
name="History Test Park",
description="Testing history",
operating_season="Summer",
operator=company
)
# Verify history using the helper method from TrackedModel
events = park.get_history()
assert events.count() == 1
event = events.first()
assert event.pgh_obj_id == park.pk
# Verify context was captured
# The middleware isn't running here, so we used pghistory.context explicitly
# But pghistory.context stores data in pgh_context field if configured?
# Let's check if the event has pgh_context
assert event.pgh_context["user"] == user.id
def test_update_tracking(self):
company = Company.objects.create(name="Test Operator 2", roles=["OPERATOR"])
park = Park.objects.create(name="Original", operator=company)
# Initial create event
assert park.get_history().count() == 1
# Update
park.name = "Updated"
park.save()
assert park.get_history().count() == 2
latest = park.get_history().first() # Ordered by -pgh_created_at
assert latest.name == "Updated"

View File

@@ -0,0 +1,53 @@
import requests
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
import logging
logger = logging.getLogger(__name__)
def get_direct_upload_url(user_id=None):
"""
Generates a direct upload URL for Cloudflare Images.
Args:
user_id (str, optional): The user ID to associate with the upload.
Returns:
dict: A dictionary containing 'id' and 'uploadURL'.
Raises:
ImproperlyConfigured: If Cloudflare settings are missing.
requests.RequestException: If the Cloudflare API request fails.
"""
account_id = getattr(settings, 'CLOUDFLARE_IMAGES_ACCOUNT_ID', None)
api_token = getattr(settings, 'CLOUDFLARE_IMAGES_API_TOKEN', None)
if not account_id or not api_token:
raise ImproperlyConfigured(
"CLOUDFLARE_IMAGES_ACCOUNT_ID and CLOUDFLARE_IMAGES_API_TOKEN must be set."
)
url = f"https://api.cloudflare.com/client/v4/accounts/{account_id}/images/v2/direct_upload"
headers = {
"Authorization": f"Bearer {api_token}",
}
data = {
"requireSignedURLs": "false",
}
if user_id:
data["metadata"] = f'{{"user_id": "{user_id}"}}'
response = requests.post(url, headers=headers, data=data)
response.raise_for_status()
result = response.json()
if not result.get("success"):
error_msg = result.get("errors", [{"message": "Unknown error"}])[0].get("message")
logger.error(f"Cloudflare Direct Upload Error: {error_msg}")
raise requests.RequestException(f"Cloudflare Error: {error_msg}")
return result.get("result", {})

View File

@@ -1,8 +1,6 @@
# Generated by Django 5.1.4 on 2025-08-13 21:35
# Generated by Django 5.1.6 on 2025-12-26 14:30
import django.db.models.deletion
import apps.media.models
import apps.media.storage
import pgtrigger.compiler
import pgtrigger.migrations
from django.conf import settings
@@ -15,6 +13,7 @@ class Migration(migrations.Migration):
dependencies = [
("contenttypes", "0002_remove_content_type_name"),
("django_cloudflareimages_toolkit", "0001_initial"),
("pghistory", "0006_delete_aggregateevent"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
@@ -23,88 +22,82 @@ class Migration(migrations.Migration):
migrations.CreateModel(
name="Photo",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"image",
models.ImageField(
max_length=255,
storage=apps.media.storage.MediaStorage(),
upload_to=apps.media.models.photo_upload_path,
),
),
("caption", models.CharField(blank=True, max_length=255)),
("alt_text", models.CharField(blank=True, max_length=255)),
("is_primary", models.BooleanField(default=False)),
("is_approved", models.BooleanField(default=False)),
("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)),
("date_taken", models.DateTimeField(blank=True, null=True)),
("object_id", models.PositiveIntegerField()),
("object_id", models.PositiveIntegerField(help_text="ID of the item")),
("caption", models.CharField(blank=True, help_text="Photo caption", max_length=255)),
("is_public", models.BooleanField(default=True, help_text="Whether this photo is visible to others")),
("source", models.CharField(blank=True, help_text="Source/Credit if applicable", max_length=100)),
(
"content_type",
models.ForeignKey(
help_text="Type of item this photo belongs to",
on_delete=django.db.models.deletion.CASCADE,
to="contenttypes.contenttype",
),
),
(
"uploaded_by",
"image",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="uploaded_photos",
help_text="Cloudflare Image reference",
on_delete=django.db.models.deletion.CASCADE,
related_name="photos_usage",
to="django_cloudflareimages_toolkit.cloudflareimage",
),
),
(
"user",
models.ForeignKey(
help_text="User who uploaded this photo",
on_delete=django.db.models.deletion.CASCADE,
related_name="photos",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"ordering": ["-is_primary", "-created_at"],
"verbose_name": "Photo",
"verbose_name_plural": "Photos",
"ordering": ["-created_at"],
"abstract": False,
},
),
migrations.CreateModel(
name="PhotoEvent",
fields=[
(
"pgh_id",
models.AutoField(primary_key=True, serialize=False),
),
("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()),
(
"image",
models.ImageField(
max_length=255,
storage=apps.media.storage.MediaStorage(),
upload_to=apps.media.models.photo_upload_path,
),
),
("caption", models.CharField(blank=True, max_length=255)),
("alt_text", models.CharField(blank=True, max_length=255)),
("is_primary", models.BooleanField(default=False)),
("is_approved", models.BooleanField(default=False)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("date_taken", models.DateTimeField(blank=True, null=True)),
("object_id", models.PositiveIntegerField()),
("object_id", models.PositiveIntegerField(help_text="ID of the item")),
("caption", models.CharField(blank=True, help_text="Photo caption", max_length=255)),
("is_public", models.BooleanField(default=True, help_text="Whether this photo is visible to others")),
("source", models.CharField(blank=True, help_text="Source/Credit if applicable", max_length=100)),
(
"content_type",
models.ForeignKey(
db_constraint=False,
help_text="Type of item this photo belongs to",
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="contenttypes.contenttype",
),
),
(
"image",
models.ForeignKey(
db_constraint=False,
help_text="Cloudflare Image reference",
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="django_cloudflareimages_toolkit.cloudflareimage",
),
),
(
"pgh_context",
models.ForeignKey(
@@ -125,10 +118,10 @@ class Migration(migrations.Migration):
),
),
(
"uploaded_by",
"user",
models.ForeignKey(
db_constraint=False,
null=True,
help_text="User who uploaded this photo",
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
@@ -142,18 +135,15 @@ class Migration(migrations.Migration):
),
migrations.AddIndex(
model_name="photo",
index=models.Index(
fields=["content_type", "object_id"],
name="media_photo_content_0187f5_idx",
),
index=models.Index(fields=["content_type", "object_id"], name="media_photo_content_0187f5_idx"),
),
pgtrigger.migrations.AddTrigger(
model_name="photo",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "media_photoevent" ("alt_text", "caption", "content_type_id", "created_at", "date_taken", "id", "image", "is_approved", "is_primary", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "updated_at", "uploaded_by_id") VALUES (NEW."alt_text", NEW."caption", NEW."content_type_id", NEW."created_at", NEW."date_taken", NEW."id", NEW."image", NEW."is_approved", NEW."is_primary", NEW."object_id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."updated_at", NEW."uploaded_by_id"); RETURN NULL;',
hash="[AWS-SECRET-REMOVED]",
func='INSERT INTO "media_photoevent" ("caption", "content_type_id", "created_at", "id", "image_id", "is_public", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "source", "updated_at", "user_id") VALUES (NEW."caption", NEW."content_type_id", NEW."created_at", NEW."id", NEW."image_id", NEW."is_public", NEW."object_id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."source", NEW."updated_at", NEW."user_id"); RETURN NULL;',
hash="05c2d557f631f80ebd4b37ffb1ba9a539fa54244",
operation="INSERT",
pgid="pgtrigger_insert_insert_e1ca0",
table="media_photo",
@@ -167,8 +157,8 @@ class Migration(migrations.Migration):
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "media_photoevent" ("alt_text", "caption", "content_type_id", "created_at", "date_taken", "id", "image", "is_approved", "is_primary", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "updated_at", "uploaded_by_id") VALUES (NEW."alt_text", NEW."caption", NEW."content_type_id", NEW."created_at", NEW."date_taken", NEW."id", NEW."image", NEW."is_approved", NEW."is_primary", NEW."object_id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."updated_at", NEW."uploaded_by_id"); RETURN NULL;',
hash="[AWS-SECRET-REMOVED]",
func='INSERT INTO "media_photoevent" ("caption", "content_type_id", "created_at", "id", "image_id", "is_public", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "source", "updated_at", "user_id") VALUES (NEW."caption", NEW."content_type_id", NEW."created_at", NEW."id", NEW."image_id", NEW."is_public", NEW."object_id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."source", NEW."updated_at", NEW."user_id"); RETURN NULL;',
hash="9a4caabe540c0fd782b9c148444c364e385327f4",
operation="UPDATE",
pgid="pgtrigger_update_update_6ff7d",
table="media_photo",

View File

@@ -0,0 +1,57 @@
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
# Using string reference for CloudflareImage to avoid circular imports if possible,
# or direct import if safe. django-cloudflare-images-toolkit usually provides a field or model.
# Checking installed apps... it's "django_cloudflareimages_toolkit".
from django_cloudflareimages_toolkit.models import CloudflareImage
@pghistory.track()
class Photo(TrackedModel):
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="photos",
help_text="User who uploaded this photo",
)
# The actual image
image = models.ForeignKey(
CloudflareImage,
on_delete=models.CASCADE,
related_name="photos_usage",
help_text="Cloudflare Image reference"
)
# Generic relation to target object (Park, Ride, etc.)
content_type = models.ForeignKey(
ContentType,
on_delete=models.CASCADE,
help_text="Type of item this photo belongs to",
)
object_id = models.PositiveIntegerField(help_text="ID of the item")
content_object = GenericForeignKey("content_type", "object_id")
# Metadata
caption = models.CharField(max_length=255, blank=True, help_text="Photo caption")
is_public = models.BooleanField(
default=True,
help_text="Whether this photo is visible to others"
)
# We might want credit/source info if not taken by user
source = models.CharField(max_length=100, blank=True, help_text="Source/Credit if applicable")
class Meta(TrackedModel.Meta):
verbose_name = "Photo"
verbose_name_plural = "Photos"
ordering = ["-created_at"]
indexes = [
models.Index(fields=["content_type", "object_id"]),
]
def __str__(self):
return f"Photo by {self.user.username} for {self.content_object}"

View File

@@ -0,0 +1,62 @@
from rest_framework import serializers
from .models import Photo
from apps.accounts.serializers import UserSerializer
from django_cloudflareimages_toolkit.models import CloudflareImage
# We need a serializer for the CloudflareImage model too if we want to show variants
class CloudflareImageSerializer(serializers.ModelSerializer):
variants = serializers.JSONField(read_only=True)
class Meta:
model = CloudflareImage
fields = ["id", "cloudflare_id", "variants"]
class PhotoSerializer(serializers.ModelSerializer):
user = UserSerializer(read_only=True)
image = CloudflareImageSerializer(read_only=True)
cloudflare_image_id = serializers.CharField(write_only=True)
# Helper for frontend to get URLs easily
url = serializers.SerializerMethodField()
thumbnail = serializers.SerializerMethodField()
class Meta:
model = Photo
fields = [
"id",
"user",
"image",
"cloudflare_image_id",
"content_type",
"object_id",
"caption",
"source",
"is_public",
"created_at",
"updated_at",
"url",
"thumbnail",
]
read_only_fields = ["id", "user", "created_at", "updated_at"]
def create(self, validated_data):
cloudflare_id = validated_data.pop("cloudflare_image_id", None)
if cloudflare_id:
# Get or create the CloudflareImage wrapper
# We assume it exists on CF side. We just need the DB record.
image, _ = CloudflareImage.objects.get_or_create(cloudflare_id=cloudflare_id)
validated_data["image"] = image
return super().create(validated_data)
def get_url(self, obj):
# Return public variant or default
if obj.image:
# Check if get_url method exists or we construct strictly
return getattr(obj.image, 'get_url', lambda x: None)('public')
return None
def get_thumbnail(self, obj):
if obj.image:
return getattr(obj.image, 'get_url', lambda x: None)('thumbnail')
return None

View File

@@ -0,0 +1,10 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import PhotoViewSet
router = DefaultRouter()
router.register(r"photos", PhotoViewSet, basename="photo")
urlpatterns = [
path("", include(router.urls)),
]

View File

@@ -0,0 +1,23 @@
from rest_framework import viewsets, permissions, filters
from django_filters.rest_framework import DjangoFilterBackend
from .models import Photo
from .serializers import PhotoSerializer
from apps.core.permissions import IsOwnerOrReadOnly
class PhotoViewSet(viewsets.ModelViewSet):
queryset = Photo.objects.filter(is_public=True)
serializer_class = PhotoSerializer
permission_classes = [permissions.IsAuthenticatedOrReadOnly, IsOwnerOrReadOnly]
filter_backends = [DjangoFilterBackend, filters.OrderingFilter]
filterset_fields = ["content_type", "object_id", "user"]
ordering_fields = ["created_at"]
ordering = ["-created_at"]
def get_queryset(self):
qs = Photo.objects.filter(is_public=True)
if self.request.user.is_authenticated:
qs = qs | Photo.objects.filter(user=self.request.user)
return qs.distinct()
def perform_create(self, serializer):
serializer.save(user=self.request.user)

View File

@@ -80,51 +80,51 @@ class ModerationConfig(AppConfig):
PhotoSubmission,
)
# EditSubmission callbacks
# EditSubmission callbacks (transitions from CLAIMED state)
register_callback(
EditSubmission, 'status', 'PENDING', 'APPROVED',
EditSubmission, 'status', 'CLAIMED', 'APPROVED',
SubmissionApprovedNotification()
)
register_callback(
EditSubmission, 'status', 'PENDING', 'APPROVED',
EditSubmission, 'status', 'CLAIMED', 'APPROVED',
ModerationCacheInvalidation()
)
register_callback(
EditSubmission, 'status', 'PENDING', 'REJECTED',
EditSubmission, 'status', 'CLAIMED', 'REJECTED',
SubmissionRejectedNotification()
)
register_callback(
EditSubmission, 'status', 'PENDING', 'REJECTED',
EditSubmission, 'status', 'CLAIMED', 'REJECTED',
ModerationCacheInvalidation()
)
register_callback(
EditSubmission, 'status', 'PENDING', 'ESCALATED',
EditSubmission, 'status', 'CLAIMED', 'ESCALATED',
SubmissionEscalatedNotification()
)
register_callback(
EditSubmission, 'status', 'PENDING', 'ESCALATED',
EditSubmission, 'status', 'CLAIMED', 'ESCALATED',
ModerationCacheInvalidation()
)
# PhotoSubmission callbacks
# PhotoSubmission callbacks (transitions from CLAIMED state)
register_callback(
PhotoSubmission, 'status', 'PENDING', 'APPROVED',
PhotoSubmission, 'status', 'CLAIMED', 'APPROVED',
SubmissionApprovedNotification()
)
register_callback(
PhotoSubmission, 'status', 'PENDING', 'APPROVED',
PhotoSubmission, 'status', 'CLAIMED', 'APPROVED',
ModerationCacheInvalidation()
)
register_callback(
PhotoSubmission, 'status', 'PENDING', 'REJECTED',
PhotoSubmission, 'status', 'CLAIMED', 'REJECTED',
SubmissionRejectedNotification()
)
register_callback(
PhotoSubmission, 'status', 'PENDING', 'REJECTED',
PhotoSubmission, 'status', 'CLAIMED', 'REJECTED',
ModerationCacheInvalidation()
)
register_callback(
PhotoSubmission, 'status', 'PENDING', 'ESCALATED',
PhotoSubmission, 'status', 'CLAIMED', 'ESCALATED',
SubmissionEscalatedNotification()
)

View File

@@ -22,12 +22,29 @@ EDIT_SUBMISSION_STATUSES = [
'icon': 'clock',
'css_class': 'bg-yellow-100 text-yellow-800 border-yellow-200',
'sort_order': 1,
'can_transition_to': ['APPROVED', 'REJECTED', 'ESCALATED'],
'can_transition_to': ['CLAIMED'], # Must be claimed before any action
'requires_moderator': True,
'is_actionable': True
},
category=ChoiceCategory.STATUS
),
RichChoice(
value="CLAIMED",
label="Claimed",
description="Submission has been claimed by a moderator for review",
metadata={
'color': 'blue',
'icon': 'user-check',
'css_class': 'bg-blue-100 text-blue-800 border-blue-200',
'sort_order': 2,
# Note: PENDING not included to avoid cycle - unclaim uses direct status update
'can_transition_to': ['APPROVED', 'REJECTED', 'ESCALATED'],
'requires_moderator': True,
'is_actionable': True,
'is_locked': True # Indicates this submission is locked for editing by others
},
category=ChoiceCategory.STATUS
),
RichChoice(
value="APPROVED",
label="Approved",
@@ -36,7 +53,7 @@ EDIT_SUBMISSION_STATUSES = [
'color': 'green',
'icon': 'check-circle',
'css_class': 'bg-green-100 text-green-800 border-green-200',
'sort_order': 2,
'sort_order': 3,
'can_transition_to': [],
'requires_moderator': True,
'is_actionable': False,
@@ -52,7 +69,7 @@ EDIT_SUBMISSION_STATUSES = [
'color': 'red',
'icon': 'x-circle',
'css_class': 'bg-red-100 text-red-800 border-red-200',
'sort_order': 3,
'sort_order': 4,
'can_transition_to': [],
'requires_moderator': True,
'is_actionable': False,
@@ -68,7 +85,7 @@ EDIT_SUBMISSION_STATUSES = [
'color': 'purple',
'icon': 'arrow-up',
'css_class': 'bg-purple-100 text-purple-800 border-purple-200',
'sort_order': 4,
'sort_order': 5,
'can_transition_to': ['APPROVED', 'REJECTED'],
'requires_moderator': True,
'is_actionable': True,

View File

@@ -0,0 +1,201 @@
# Generated by Django 5.1.6 on 2025-12-26 20:01
import apps.core.state_machine.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 = [
("moderation", "0008_alter_bulkoperation_options_and_more"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
pgtrigger.migrations.RemoveTrigger(
model_name="editsubmission",
name="insert_insert",
),
pgtrigger.migrations.RemoveTrigger(
model_name="editsubmission",
name="update_update",
),
pgtrigger.migrations.RemoveTrigger(
model_name="photosubmission",
name="insert_insert",
),
pgtrigger.migrations.RemoveTrigger(
model_name="photosubmission",
name="update_update",
),
migrations.AddField(
model_name="editsubmission",
name="claimed_at",
field=models.DateTimeField(blank=True, help_text="When this submission was claimed", null=True),
),
migrations.AddField(
model_name="editsubmission",
name="claimed_by",
field=models.ForeignKey(
blank=True,
help_text="Moderator who has claimed this submission for review",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="claimed_edit_submissions",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AddField(
model_name="editsubmissionevent",
name="claimed_at",
field=models.DateTimeField(blank=True, help_text="When this submission was claimed", null=True),
),
migrations.AddField(
model_name="editsubmissionevent",
name="claimed_by",
field=models.ForeignKey(
blank=True,
db_constraint=False,
help_text="Moderator who has claimed this submission for review",
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AddField(
model_name="photosubmission",
name="claimed_at",
field=models.DateTimeField(blank=True, help_text="When this submission was claimed", null=True),
),
migrations.AddField(
model_name="photosubmission",
name="claimed_by",
field=models.ForeignKey(
blank=True,
help_text="Moderator who has claimed this submission for review",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="claimed_photo_submissions",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AddField(
model_name="photosubmissionevent",
name="claimed_at",
field=models.DateTimeField(blank=True, help_text="When this submission was claimed", null=True),
),
migrations.AddField(
model_name="photosubmissionevent",
name="claimed_by",
field=models.ForeignKey(
blank=True,
db_constraint=False,
help_text="Moderator who has claimed this submission for review",
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="status",
field=apps.core.state_machine.fields.RichFSMField(
allow_deprecated=False,
choice_group="edit_submission_statuses",
choices=[
("PENDING", "Pending"),
("CLAIMED", "Claimed"),
("APPROVED", "Approved"),
("REJECTED", "Rejected"),
("ESCALATED", "Escalated"),
],
default="PENDING",
domain="moderation",
max_length=20,
),
),
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"),
("CLAIMED", "Claimed"),
("APPROVED", "Approved"),
("REJECTED", "Rejected"),
("ESCALATED", "Escalated"),
],
default="PENDING",
domain="moderation",
max_length=20,
),
),
pgtrigger.migrations.AddTrigger(
model_name="editsubmission",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "moderation_editsubmissionevent" ("changes", "claimed_at", "claimed_by_id", "content_type_id", "created_at", "handled_at", "handled_by_id", "id", "moderator_changes", "notes", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "reason", "source", "status", "submission_type", "updated_at", "user_id") VALUES (NEW."changes", NEW."claimed_at", NEW."claimed_by_id", NEW."content_type_id", NEW."created_at", NEW."handled_at", NEW."handled_by_id", NEW."id", NEW."moderator_changes", NEW."notes", NEW."object_id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."reason", NEW."source", NEW."status", NEW."submission_type", NEW."updated_at", NEW."user_id"); RETURN NULL;',
hash="947e1d596310a6e4aad4f30724fbd2e2294d977b",
operation="INSERT",
pgid="pgtrigger_insert_insert_2c796",
table="moderation_editsubmission",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="editsubmission",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "moderation_editsubmissionevent" ("changes", "claimed_at", "claimed_by_id", "content_type_id", "created_at", "handled_at", "handled_by_id", "id", "moderator_changes", "notes", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "reason", "source", "status", "submission_type", "updated_at", "user_id") VALUES (NEW."changes", NEW."claimed_at", NEW."claimed_by_id", NEW."content_type_id", NEW."created_at", NEW."handled_at", NEW."handled_by_id", NEW."id", NEW."moderator_changes", NEW."notes", NEW."object_id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."reason", NEW."source", NEW."status", NEW."submission_type", NEW."updated_at", NEW."user_id"); RETURN NULL;',
hash="568618c5161ed78a9c72d751f1c312c64dea3994",
operation="UPDATE",
pgid="pgtrigger_update_update_ab38f",
table="moderation_editsubmission",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="photosubmission",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "moderation_photosubmissionevent" ("caption", "claimed_at", "claimed_by_id", "content_type_id", "created_at", "date_taken", "handled_at", "handled_by_id", "id", "notes", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "photo_id", "status", "updated_at", "user_id") VALUES (NEW."caption", NEW."claimed_at", NEW."claimed_by_id", NEW."content_type_id", NEW."created_at", NEW."date_taken", NEW."handled_at", NEW."handled_by_id", NEW."id", NEW."notes", NEW."object_id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."photo_id", NEW."status", NEW."updated_at", NEW."user_id"); RETURN NULL;',
hash="483cca8949361fe83eb0a964f9f454c5d2c1ac22",
operation="INSERT",
pgid="pgtrigger_insert_insert_62865",
table="moderation_photosubmission",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="photosubmission",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "moderation_photosubmissionevent" ("caption", "claimed_at", "claimed_by_id", "content_type_id", "created_at", "date_taken", "handled_at", "handled_by_id", "id", "notes", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "photo_id", "status", "updated_at", "user_id") VALUES (NEW."caption", NEW."claimed_at", NEW."claimed_by_id", NEW."content_type_id", NEW."created_at", NEW."date_taken", NEW."handled_at", NEW."handled_by_id", NEW."id", NEW."notes", NEW."object_id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."photo_id", NEW."status", NEW."updated_at", NEW."user_id"); RETURN NULL;',
hash="82c7edd7b108f50aed0b6b06e44786617792171c",
operation="UPDATE",
pgid="pgtrigger_update_update_9c311",
table="moderation_photosubmission",
when="AFTER",
),
),
),
]

View File

@@ -143,6 +143,19 @@ class EditSubmission(StateMachineMixin, TrackedModel):
blank=True, help_text="Notes from the moderator about this submission"
)
# Claim tracking for concurrency control
claimed_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="claimed_edit_submissions",
help_text="Moderator who has claimed this submission for review",
)
claimed_at = models.DateTimeField(
null=True, blank=True, help_text="When this submission was claimed"
)
class Meta(TrackedModel.Meta):
verbose_name = "Edit Submission"
verbose_name_plural = "Edit Submissions"
@@ -188,6 +201,54 @@ class EditSubmission(StateMachineMixin, TrackedModel):
"""Get the final changes to apply (moderator changes if available, otherwise original changes)"""
return self.moderator_changes or self.changes
def claim(self, user: UserType) -> None:
"""
Claim this submission for review.
Transition: PENDING -> CLAIMED
Args:
user: The moderator claiming this submission
Raises:
ValidationError: If submission is not in PENDING state
"""
from django.core.exceptions import ValidationError
if self.status != "PENDING":
raise ValidationError(
f"Cannot claim submission: current status is {self.status}, expected PENDING"
)
self.transition_to_claimed(user=user)
self.claimed_by = user
self.claimed_at = timezone.now()
self.save()
def unclaim(self, user: UserType = None) -> None:
"""
Release claim on this submission.
Transition: CLAIMED -> PENDING
Args:
user: The user initiating the unclaim (for audit)
Raises:
ValidationError: If submission is not in CLAIMED state
"""
from django.core.exceptions import ValidationError
if self.status != "CLAIMED":
raise ValidationError(
f"Cannot unclaim submission: current status is {self.status}, expected CLAIMED"
)
# Set status directly (not via FSM transition to avoid cycle)
# This is intentional - the unclaim action is a special "rollback" operation
self.status = "PENDING"
self.claimed_by = None
self.claimed_at = None
self.save()
def approve(self, moderator: UserType, user=None) -> Optional[models.Model]:
"""
Approve this submission and apply the changes.
@@ -204,9 +265,17 @@ class EditSubmission(StateMachineMixin, TrackedModel):
ValueError: If submission cannot be approved
ValidationError: If the data is invalid
"""
from django.core.exceptions import ValidationError
# Use user parameter if provided (FSM convention)
approver = user or moderator
# Validate state - must be CLAIMED before approval
if self.status != "CLAIMED":
raise ValidationError(
f"Cannot approve submission: must be CLAIMED first (current status: {self.status})"
)
model_class = self.content_type.model_class()
if not model_class:
raise ValueError("Could not resolve model class")
@@ -263,9 +332,17 @@ class EditSubmission(StateMachineMixin, TrackedModel):
reason: Reason for rejection
user: Alternative parameter for FSM compatibility
"""
from django.core.exceptions import ValidationError
# Use user parameter if provided (FSM convention)
rejecter = user or moderator
# Validate state - must be CLAIMED before rejection
if self.status != "CLAIMED":
raise ValidationError(
f"Cannot reject submission: must be CLAIMED first (current status: {self.status})"
)
# Use FSM transition to update status
self.transition_to_rejected(user=rejecter)
self.handled_by = rejecter
@@ -283,9 +360,17 @@ class EditSubmission(StateMachineMixin, TrackedModel):
reason: Reason for escalation
user: Alternative parameter for FSM compatibility
"""
from django.core.exceptions import ValidationError
# Use user parameter if provided (FSM convention)
escalator = user or moderator
# Validate state - must be CLAIMED before escalation
if self.status != "CLAIMED":
raise ValidationError(
f"Cannot escalate submission: must be CLAIMED first (current status: {self.status})"
)
# Use FSM transition to update status
self.transition_to_escalated(user=escalator)
self.handled_by = escalator
@@ -747,6 +832,19 @@ class PhotoSubmission(StateMachineMixin, TrackedModel):
help_text="Notes from the moderator about this photo submission",
)
# Claim tracking for concurrency control
claimed_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="claimed_photo_submissions",
help_text="Moderator who has claimed this submission for review",
)
claimed_at = models.DateTimeField(
null=True, blank=True, help_text="When this submission was claimed"
)
class Meta(TrackedModel.Meta):
verbose_name = "Photo Submission"
verbose_name_plural = "Photo Submissions"
@@ -759,6 +857,54 @@ class PhotoSubmission(StateMachineMixin, TrackedModel):
def __str__(self) -> str:
return f"Photo submission by {self.user.username} for {self.content_object}"
def claim(self, user: UserType) -> None:
"""
Claim this photo submission for review.
Transition: PENDING -> CLAIMED
Args:
user: The moderator claiming this submission
Raises:
ValidationError: If submission is not in PENDING state
"""
from django.core.exceptions import ValidationError
if self.status != "PENDING":
raise ValidationError(
f"Cannot claim submission: current status is {self.status}, expected PENDING"
)
self.transition_to_claimed(user=user)
self.claimed_by = user
self.claimed_at = timezone.now()
self.save()
def unclaim(self, user: UserType = None) -> None:
"""
Release claim on this photo submission.
Transition: CLAIMED -> PENDING
Args:
user: The user initiating the unclaim (for audit)
Raises:
ValidationError: If submission is not in CLAIMED state
"""
from django.core.exceptions import ValidationError
if self.status != "CLAIMED":
raise ValidationError(
f"Cannot unclaim submission: current status is {self.status}, expected CLAIMED"
)
# Set status directly (not via FSM transition to avoid cycle)
# This is intentional - the unclaim action is a special "rollback" operation
self.status = "PENDING"
self.claimed_by = None
self.claimed_at = None
self.save()
def approve(self, moderator: UserType = None, notes: str = "", user=None) -> None:
"""
Approve the photo submission.
@@ -771,10 +917,17 @@ class PhotoSubmission(StateMachineMixin, TrackedModel):
"""
from apps.parks.models.media import ParkPhoto
from apps.rides.models.media import RidePhoto
from django.core.exceptions import ValidationError
# Use user parameter if provided (FSM convention)
approver = user or moderator
# Validate state - must be CLAIMED before approval
if self.status != "CLAIMED":
raise ValidationError(
f"Cannot approve photo submission: must be CLAIMED first (current status: {self.status})"
)
# Determine the correct photo model based on the content type
model_class = self.content_type.model_class()
if model_class.__name__ == "Park":
@@ -810,9 +963,17 @@ class PhotoSubmission(StateMachineMixin, TrackedModel):
notes: Rejection reason
user: Alternative parameter for FSM compatibility
"""
from django.core.exceptions import ValidationError
# Use user parameter if provided (FSM convention)
rejecter = user or moderator
# Validate state - must be CLAIMED before rejection
if self.status != "CLAIMED":
raise ValidationError(
f"Cannot reject photo submission: must be CLAIMED first (current status: {self.status})"
)
# Use FSM transition to update status
self.transition_to_rejected(user=rejecter)
self.handled_by = rejecter # type: ignore
@@ -839,9 +1000,17 @@ class PhotoSubmission(StateMachineMixin, TrackedModel):
notes: Escalation reason
user: Alternative parameter for FSM compatibility
"""
from django.core.exceptions import ValidationError
# Use user parameter if provided (FSM convention)
escalator = user or moderator
# Validate state - must be CLAIMED before escalation
if self.status != "CLAIMED":
raise ValidationError(
f"Cannot escalate photo submission: must be CLAIMED first (current status: {self.status})"
)
# Use FSM transition to update status
self.transition_to_escalated(user=escalator)
self.handled_by = escalator # type: ignore

View File

@@ -22,6 +22,7 @@ from .models import (
ModerationAction,
BulkOperation,
EditSubmission,
PhotoSubmission,
)
User = get_user_model()
@@ -65,6 +66,7 @@ class EditSubmissionSerializer(serializers.ModelSerializer):
"""Serializer for EditSubmission with UI metadata for Nuxt frontend."""
submitted_by = UserBasicSerializer(source="user", read_only=True)
claimed_by = UserBasicSerializer(read_only=True)
content_type_name = serializers.CharField(
source="content_type.model", read_only=True
)
@@ -91,6 +93,8 @@ class EditSubmissionSerializer(serializers.ModelSerializer):
"rejection_reason",
"submitted_by",
"reviewed_by",
"claimed_by",
"claimed_at",
"created_at",
"updated_at",
"time_since_created",
@@ -100,6 +104,8 @@ class EditSubmissionSerializer(serializers.ModelSerializer):
"created_at",
"updated_at",
"submitted_by",
"claimed_by",
"claimed_at",
"status_color",
"status_icon",
"status_display",
@@ -111,6 +117,7 @@ class EditSubmissionSerializer(serializers.ModelSerializer):
"""Return hex color based on status for UI badges."""
colors = {
"PENDING": "#f59e0b", # Amber
"CLAIMED": "#3b82f6", # Blue
"APPROVED": "#10b981", # Emerald
"REJECTED": "#ef4444", # Red
"ESCALATED": "#8b5cf6", # Violet
@@ -121,6 +128,7 @@ class EditSubmissionSerializer(serializers.ModelSerializer):
"""Return Heroicons icon name based on status."""
icons = {
"PENDING": "heroicons:clock",
"CLAIMED": "heroicons:user-circle",
"APPROVED": "heroicons:check-circle",
"REJECTED": "heroicons:x-circle",
"ESCALATED": "heroicons:arrow-up-circle",
@@ -148,6 +156,9 @@ class EditSubmissionListSerializer(serializers.ModelSerializer):
submitted_by_username = serializers.CharField(
source="user.username", read_only=True
)
claimed_by_username = serializers.CharField(
source="claimed_by.username", read_only=True, allow_null=True
)
content_type_name = serializers.CharField(
source="content_type.model", read_only=True
)
@@ -162,6 +173,8 @@ class EditSubmissionListSerializer(serializers.ModelSerializer):
"content_type_name",
"object_id",
"submitted_by_username",
"claimed_by_username",
"claimed_at",
"status_color",
"status_icon",
"created_at",
@@ -171,6 +184,7 @@ class EditSubmissionListSerializer(serializers.ModelSerializer):
def get_status_color(self, obj) -> str:
colors = {
"PENDING": "#f59e0b",
"CLAIMED": "#3b82f6",
"APPROVED": "#10b981",
"REJECTED": "#ef4444",
"ESCALATED": "#8b5cf6",
@@ -180,6 +194,7 @@ class EditSubmissionListSerializer(serializers.ModelSerializer):
def get_status_icon(self, obj) -> str:
icons = {
"PENDING": "heroicons:clock",
"CLAIMED": "heroicons:user-circle",
"APPROVED": "heroicons:check-circle",
"REJECTED": "heroicons:x-circle",
"ESCALATED": "heroicons:arrow-up-circle",
@@ -911,3 +926,90 @@ class StateLogSerializer(serializers.ModelSerializer):
]
read_only_fields = fields
class PhotoSubmissionSerializer(serializers.ModelSerializer):
"""Serializer for PhotoSubmission."""
submitted_by = UserBasicSerializer(source="user", read_only=True)
content_type_name = serializers.CharField(
source="content_type.model", read_only=True
)
photo_url = serializers.SerializerMethodField()
# UI Metadata
status_display = serializers.CharField(source="get_status_display", read_only=True)
status_color = serializers.SerializerMethodField()
status_icon = serializers.SerializerMethodField()
time_since_created = serializers.SerializerMethodField()
class Meta:
model = PhotoSubmission
fields = [
"id",
"status",
"status_display",
"status_color",
"status_icon",
"content_type",
"content_type_name",
"object_id",
"photo",
"photo_url",
"caption",
"date_taken",
"submitted_by",
"handled_by",
"handled_at",
"notes",
"created_at",
"time_since_created",
]
read_only_fields = [
"id",
"created_at",
"submitted_by",
"handled_by",
"handled_at",
"status_display",
"status_color",
"status_icon",
"content_type_name",
"photo_url",
"time_since_created",
]
def get_photo_url(self, obj) -> str | None:
if obj.photo:
return obj.photo.image_url
return None
def get_status_color(self, obj) -> str:
colors = {
"PENDING": "#f59e0b",
"APPROVED": "#10b981",
"REJECTED": "#ef4444",
}
return colors.get(obj.status, "#6b7280")
def get_status_icon(self, obj) -> str:
icons = {
"PENDING": "heroicons:clock",
"APPROVED": "heroicons:check-circle",
"REJECTED": "heroicons:x-circle",
}
return icons.get(obj.status, "heroicons:question-mark-circle")
def get_time_since_created(self, obj) -> str:
"""Human-readable time since creation."""
now = timezone.now()
diff = now - obj.created_at
if diff.days > 0:
return f"{diff.days} days ago"
elif diff.seconds > 3600:
hours = diff.seconds // 3600
return f"{hours} hours ago"
else:
minutes = diff.seconds // 60
return f"{minutes} minutes ago"

View File

@@ -4,12 +4,17 @@ Signal handlers for moderation-related FSM state transitions.
This module provides signal handlers that execute when moderation
models (EditSubmission, PhotoSubmission, ModerationReport, etc.)
undergo state transitions.
Includes:
- Transition handlers for approval, rejection, escalation
- Real-time broadcasting signal for dashboard updates
- Claim/unclaim tracking for concurrency control
"""
import logging
from django.conf import settings
from django.dispatch import receiver
from django.dispatch import receiver, Signal
from apps.core.state_machine.signals import (
post_state_transition,
@@ -20,6 +25,71 @@ from apps.core.state_machine.signals import (
logger = logging.getLogger(__name__)
# ============================================================================
# Custom Signals for Real-Time Broadcasting
# ============================================================================
# Signal emitted when a submission status changes - for real-time UI updates
# Arguments:
# - sender: The model class (EditSubmission or PhotoSubmission)
# - submission_id: The ID of the submission
# - submission_type: "edit" or "photo"
# - new_status: The new status value
# - previous_status: The previous status value
# - locked_by: Username of the moderator who claimed it (or None)
# - payload: Full payload dictionary for broadcasting
submission_status_changed = Signal()
def handle_submission_claimed(instance, source, target, user, context=None, **kwargs):
"""
Handle submission claim transitions.
Called when an EditSubmission or PhotoSubmission is claimed by a moderator.
Broadcasts the status change for real-time dashboard updates.
Args:
instance: The submission instance.
source: The source state.
target: The target state.
user: The user who claimed.
context: Optional TransitionContext.
"""
if target != 'CLAIMED':
return
logger.info(
f"Submission {instance.pk} claimed by {user.username if user else 'system'}"
)
# Broadcast for real-time dashboard updates
_broadcast_submission_status_change(instance, source, target, user)
def handle_submission_unclaimed(instance, source, target, user, context=None, **kwargs):
"""
Handle submission unclaim transitions (CLAIMED -> PENDING).
Called when a moderator releases their claim on a submission.
Args:
instance: The submission instance.
source: The source state.
target: The target state.
user: The user who unclaimed.
context: Optional TransitionContext.
"""
if source != 'CLAIMED' or target != 'PENDING':
return
logger.info(
f"Submission {instance.pk} unclaimed by {user.username if user else 'system'}"
)
# Broadcast for real-time dashboard updates
_broadcast_submission_status_change(instance, source, target, user)
def handle_submission_approved(instance, source, target, user, context=None, **kwargs):
"""
Handle submission approval transitions.
@@ -255,6 +325,66 @@ def _finalize_bulk_operation(instance, success):
logger.warning(f"Failed to finalize bulk operation: {e}")
def _broadcast_submission_status_change(instance, source, target, user):
"""
Broadcast submission status change for real-time UI updates.
Emits the submission_status_changed signal with a structured payload
that can be consumed by notification systems (Novu, SSE, WebSocket, etc.).
Payload format:
{
"submission_id": 123,
"submission_type": "edit" | "photo",
"new_status": "CLAIMED",
"previous_status": "PENDING",
"locked_by": "moderator_username" | None,
"locked_at": "2024-01-01T12:00:00Z" | None,
"changed_by": "username" | None,
}
"""
try:
from .models import EditSubmission, PhotoSubmission
# Determine submission type
submission_type = "edit" if isinstance(instance, EditSubmission) else "photo"
# Build the broadcast payload
payload = {
"submission_id": instance.pk,
"submission_type": submission_type,
"new_status": target,
"previous_status": source,
"locked_by": None,
"locked_at": None,
"changed_by": user.username if user else None,
}
# Add claim information if available
if hasattr(instance, 'claimed_by') and instance.claimed_by:
payload["locked_by"] = instance.claimed_by.username
if hasattr(instance, 'claimed_at') and instance.claimed_at:
payload["locked_at"] = instance.claimed_at.isoformat()
# Emit the signal for downstream notification handlers
submission_status_changed.send(
sender=type(instance),
submission_id=instance.pk,
submission_type=submission_type,
new_status=target,
previous_status=source,
locked_by=payload["locked_by"],
payload=payload,
)
logger.debug(
f"Broadcast status change: {submission_type}#{instance.pk} "
f"{source} -> {target}"
)
except Exception as e:
logger.warning(f"Failed to broadcast submission status change: {e}")
# Signal handler registration
def register_moderation_signal_handlers():
@@ -320,7 +450,41 @@ def register_moderation_signal_handlers():
handle_bulk_operation_status, stage='post'
)
# Claim/Unclaim handlers for EditSubmission
register_transition_handler(
EditSubmission, 'PENDING', 'CLAIMED',
handle_submission_claimed, stage='post'
)
register_transition_handler(
EditSubmission, 'CLAIMED', 'PENDING',
handle_submission_unclaimed, stage='post'
)
# Claim/Unclaim handlers for PhotoSubmission
register_transition_handler(
PhotoSubmission, 'PENDING', 'CLAIMED',
handle_submission_claimed, stage='post'
)
register_transition_handler(
PhotoSubmission, 'CLAIMED', 'PENDING',
handle_submission_unclaimed, stage='post'
)
logger.info("Registered moderation signal handlers")
except ImportError as e:
logger.warning(f"Could not register moderation signal handlers: {e}")
__all__ = [
'submission_status_changed',
'register_moderation_signal_handlers',
'handle_submission_approved',
'handle_submission_rejected',
'handle_submission_escalated',
'handle_submission_claimed',
'handle_submission_unclaimed',
'handle_report_resolved',
'handle_queue_completed',
'handle_bulk_operation_status',
]

View File

@@ -0,0 +1,185 @@
"""
Server-Sent Events (SSE) endpoint for real-time moderation dashboard updates.
This module provides a streaming HTTP response that broadcasts submission status
changes to connected moderators in real-time.
"""
import json
import logging
import queue
import threading
import time
from typing import Generator
from django.http import StreamingHttpResponse, JsonResponse
from django.views import View
from django.contrib.auth.mixins import LoginRequiredMixin
from rest_framework.views import APIView
from rest_framework.permissions import IsAuthenticated
from apps.moderation.permissions import CanViewModerationData
from apps.moderation.signals import submission_status_changed
logger = logging.getLogger(__name__)
# Thread-safe queue for broadcasting events to all connected clients
class SSEBroadcaster:
"""
Manages SSE connections and broadcasts events to all clients.
Uses a simple subscriber pattern where each connected client
gets its own queue of events to consume.
"""
def __init__(self):
self._subscribers: list[queue.Queue] = []
self._lock = threading.Lock()
def subscribe(self) -> queue.Queue:
"""Create a new subscriber queue and register it."""
client_queue = queue.Queue()
with self._lock:
self._subscribers.append(client_queue)
logger.debug(f"SSE client subscribed. Total clients: {len(self._subscribers)}")
return client_queue
def unsubscribe(self, client_queue: queue.Queue):
"""Remove a subscriber queue."""
with self._lock:
if client_queue in self._subscribers:
self._subscribers.remove(client_queue)
logger.debug(f"SSE client unsubscribed. Total clients: {len(self._subscribers)}")
def broadcast(self, event_data: dict):
"""Send an event to all connected clients."""
with self._lock:
for client_queue in self._subscribers:
try:
client_queue.put_nowait(event_data)
except queue.Full:
logger.warning("SSE client queue full, dropping event")
# Global broadcaster instance
sse_broadcaster = SSEBroadcaster()
def handle_submission_status_changed(sender, payload, **kwargs):
"""
Signal handler that broadcasts submission status changes to SSE clients.
Connected to the submission_status_changed signal from signals.py.
"""
sse_broadcaster.broadcast(payload)
logger.debug(f"Broadcast SSE event: {payload.get('submission_type')}#{payload.get('submission_id')}")
# Connect the signal handler
submission_status_changed.connect(handle_submission_status_changed)
class ModerationSSEView(APIView):
"""
Server-Sent Events endpoint for real-time moderation updates.
Provides a streaming response that sends submission status changes
as they occur. Clients should connect to this endpoint and keep
the connection open to receive real-time updates.
Response format (SSE):
data: {"submission_id": 1, "new_status": "CLAIMED", ...}
Usage:
const eventSource = new EventSource('/api/moderation/sse/')
eventSource.onmessage = (event) => {
const data = JSON.parse(event.data)
// Handle update
}
"""
permission_classes = [IsAuthenticated, CanViewModerationData]
def get(self, request):
"""
Establish SSE connection and stream events.
Sends a heartbeat every 30 seconds to keep the connection alive.
"""
def event_stream() -> Generator[str, None, None]:
client_queue = sse_broadcaster.subscribe()
try:
# Send initial connection event
yield f"data: {json.dumps({'type': 'connected', 'message': 'SSE connection established'})}\n\n"
while True:
try:
# Wait for event with timeout for heartbeat
event = client_queue.get(timeout=30)
yield f"data: {json.dumps(event)}\n\n"
except queue.Empty:
# Send heartbeat to keep connection alive
yield f": heartbeat\n\n"
except GeneratorExit:
# Client disconnected
sse_broadcaster.unsubscribe(client_queue)
finally:
sse_broadcaster.unsubscribe(client_queue)
response = StreamingHttpResponse(
event_stream(),
content_type='text/event-stream'
)
response['Cache-Control'] = 'no-cache'
response['X-Accel-Buffering'] = 'no' # Disable nginx buffering
response['Connection'] = 'keep-alive'
return response
class ModerationSSETestView(APIView):
"""
Test endpoint to manually trigger an SSE event.
This is useful for testing the SSE connection without making
actual state transitions.
POST /api/moderation/sse/test/
{
"submission_id": 1,
"submission_type": "edit",
"new_status": "CLAIMED",
"previous_status": "PENDING"
}
"""
permission_classes = [IsAuthenticated, CanViewModerationData]
def post(self, request):
"""Broadcast a test event."""
test_payload = {
"submission_id": request.data.get("submission_id", 999),
"submission_type": request.data.get("submission_type", "edit"),
"new_status": request.data.get("new_status", "CLAIMED"),
"previous_status": request.data.get("previous_status", "PENDING"),
"locked_by": request.user.username,
"locked_at": None,
"changed_by": request.user.username,
"test": True,
}
sse_broadcaster.broadcast(test_payload)
return JsonResponse({
"status": "ok",
"message": f"Test event broadcast to {len(sse_broadcaster._subscribers)} clients",
"payload": test_payload,
})
__all__ = [
'ModerationSSEView',
'ModerationSSETestView',
'sse_broadcaster',
]

View File

@@ -16,7 +16,10 @@ from .views import (
ModerationActionViewSet,
BulkOperationViewSet,
UserModerationViewSet,
EditSubmissionViewSet,
PhotoSubmissionViewSet,
)
from .sse import ModerationSSEView, ModerationSSETestView
from apps.core.views.views import FSMTransitionView
@@ -68,9 +71,16 @@ router.register(r"queue", ModerationQueueViewSet, basename="moderation-queue")
router.register(r"actions", ModerationActionViewSet, basename="moderation-actions")
router.register(r"bulk-operations", BulkOperationViewSet, basename="bulk-operations")
router.register(r"users", UserModerationViewSet, basename="user-moderation")
# EditSubmission - register under both names for compatibility
router.register(r"submissions", EditSubmissionViewSet, basename="submissions")
router.register(r"edit-submissions", EditSubmissionViewSet, basename="edit-submissions")
# PhotoSubmission - register under both names for compatibility
router.register(r"photos", PhotoSubmissionViewSet, basename="photos")
router.register(r"photo-submissions", PhotoSubmissionViewSet, basename="photo-submissions")
app_name = "moderation"
# FSM transition convenience URLs for moderation models
fsm_transition_patterns = [
# EditSubmission transitions
@@ -161,9 +171,17 @@ html_patterns = [
path("history/", HistoryPageView.as_view(), name="history"),
]
# SSE endpoints for real-time updates
sse_patterns = [
path("sse/", ModerationSSEView.as_view(), name="moderation-sse"),
path("sse/test/", ModerationSSETestView.as_view(), name="moderation-sse-test"),
]
urlpatterns = [
# HTML page views
*html_patterns,
# SSE endpoints
*sse_patterns,
# Include all router URLs (API endpoints)
path("api/", include(router.urls)),
# FSM transition convenience endpoints

View File

@@ -34,6 +34,8 @@ from .models import (
ModerationQueue,
ModerationAction,
BulkOperation,
EditSubmission,
PhotoSubmission,
)
from .serializers import (
ModerationReportSerializer,
@@ -47,6 +49,9 @@ from .serializers import (
BulkOperationSerializer,
CreateBulkOperationSerializer,
UserModerationProfileSerializer,
EditSubmissionSerializer,
EditSubmissionListSerializer,
PhotoSubmissionSerializer,
)
from .filters import (
ModerationReportFilter,
@@ -1166,6 +1171,28 @@ class UserModerationViewSet(viewsets.ViewSet):
# Default serializer for schema generation
serializer_class = UserModerationProfileSerializer
def list(self, request):
"""Search for users to moderate."""
query = request.query_params.get("q", "")
if not query:
return Response([])
queryset = User.objects.filter(
Q(username__icontains=query) | Q(email__icontains=query)
)[:20]
users_data = [
{
"id": user.id,
"username": user.username,
"email": user.email,
"role": getattr(user, "role", "USER"),
"is_active": user.is_active,
}
for user in queryset
]
return Response(users_data)
def retrieve(self, request, pk=None):
"""Get moderation profile for a specific user."""
try:
@@ -1367,3 +1394,345 @@ class UserModerationViewSet(viewsets.ViewSet):
}
return Response(stats_data)
# ============================================================================
# Submission ViewSets
# ============================================================================
class EditSubmissionViewSet(viewsets.ModelViewSet):
"""
ViewSet for managing edit submissions.
Includes claim/unclaim endpoints with concurrency protection using
database row locking (select_for_update) to prevent race conditions.
"""
queryset = EditSubmission.objects.all()
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
search_fields = ["reason", "changes"]
ordering_fields = ["created_at", "status"]
ordering = ["-created_at"]
permission_classes = [CanViewModerationData]
def get_serializer_class(self):
if self.action == "list":
return EditSubmissionListSerializer
return EditSubmissionSerializer
def get_queryset(self):
queryset = super().get_queryset()
status = self.request.query_params.get("status")
if status:
queryset = queryset.filter(status=status)
# User filter
user_id = self.request.query_params.get("user")
if user_id:
queryset = queryset.filter(user_id=user_id)
return queryset
@action(detail=True, methods=["post"], permission_classes=[IsModeratorOrAdmin])
def claim(self, request, pk=None):
"""
Claim a submission for review with concurrency protection.
Uses select_for_update() to acquire a database row lock,
preventing race conditions when multiple moderators try to
claim the same submission simultaneously.
Returns:
200: Submission successfully claimed
404: Submission not found
409: Submission already claimed or being claimed by another moderator
400: Invalid state for claiming
"""
from django.db import transaction, DatabaseError
from django.core.exceptions import ValidationError
with transaction.atomic():
try:
# Lock the row for update - other transactions will fail immediately
submission = EditSubmission.objects.select_for_update(nowait=True).get(pk=pk)
except EditSubmission.DoesNotExist:
return Response(
{"error": "Submission not found"},
status=status.HTTP_404_NOT_FOUND
)
except DatabaseError:
# Row is already locked by another transaction
return Response(
{"error": "Submission is being claimed by another moderator. Please try again."},
status=status.HTTP_409_CONFLICT
)
# Check if already claimed
if submission.status == "CLAIMED":
return Response(
{
"error": "Submission already claimed",
"claimed_by": submission.claimed_by.username if submission.claimed_by else None,
"claimed_at": submission.claimed_at.isoformat() if submission.claimed_at else None,
},
status=status.HTTP_409_CONFLICT
)
# Check if in valid state for claiming
if submission.status != "PENDING":
return Response(
{"error": f"Cannot claim submission in {submission.status} state"},
status=status.HTTP_400_BAD_REQUEST
)
try:
submission.claim(user=request.user)
log_business_event(
logger,
event_type="submission_claimed",
message=f"EditSubmission {submission.id} claimed by {request.user.username}",
context={
"model": "EditSubmission",
"object_id": submission.id,
"claimed_by": request.user.username,
},
request=request,
)
return Response(self.get_serializer(submission).data)
except ValidationError as e:
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
@action(detail=True, methods=["post"], permission_classes=[IsModeratorOrAdmin])
def unclaim(self, request, pk=None):
"""
Release claim on a submission.
Only the claiming moderator or an admin can unclaim a submission.
"""
from django.core.exceptions import ValidationError
submission = self.get_object()
# Only the claiming user or an admin can unclaim
if submission.claimed_by != request.user and not request.user.is_staff:
return Response(
{"error": "Only the claiming moderator or an admin can unclaim"},
status=status.HTTP_403_FORBIDDEN
)
if submission.status != "CLAIMED":
return Response(
{"error": "Submission is not claimed"},
status=status.HTTP_400_BAD_REQUEST
)
try:
submission.unclaim(user=request.user)
log_business_event(
logger,
event_type="submission_unclaimed",
message=f"EditSubmission {submission.id} unclaimed by {request.user.username}",
context={
"model": "EditSubmission",
"object_id": submission.id,
"unclaimed_by": request.user.username,
},
request=request,
)
return Response(self.get_serializer(submission).data)
except ValidationError as e:
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
@action(detail=True, methods=["post"], permission_classes=[IsModeratorOrAdmin])
def approve(self, request, pk=None):
submission = self.get_object()
user = request.user
try:
submission.approve(moderator=user)
return Response(self.get_serializer(submission).data)
except Exception as e:
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
@action(detail=True, methods=["post"], permission_classes=[IsModeratorOrAdmin])
def reject(self, request, pk=None):
submission = self.get_object()
user = request.user
reason = request.data.get("reason", "")
try:
submission.reject(moderator=user, reason=reason)
return Response(self.get_serializer(submission).data)
except Exception as e:
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
@action(detail=True, methods=["post"], permission_classes=[IsModeratorOrAdmin])
def escalate(self, request, pk=None):
submission = self.get_object()
user = request.user
reason = request.data.get("reason", "")
try:
submission.escalate(moderator=user, reason=reason)
return Response(self.get_serializer(submission).data)
except Exception as e:
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
class PhotoSubmissionViewSet(viewsets.ModelViewSet):
"""
ViewSet for managing photo submissions.
Includes claim/unclaim endpoints with concurrency protection using
database row locking (select_for_update) to prevent race conditions.
"""
queryset = PhotoSubmission.objects.all()
serializer_class = PhotoSubmissionSerializer
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
search_fields = ["caption", "notes"]
ordering_fields = ["created_at", "status"]
ordering = ["-created_at"]
permission_classes = [CanViewModerationData]
def get_queryset(self):
queryset = super().get_queryset()
status = self.request.query_params.get("status")
if status:
queryset = queryset.filter(status=status)
# User filter
user_id = self.request.query_params.get("user")
if user_id:
queryset = queryset.filter(user_id=user_id)
return queryset
@action(detail=True, methods=["post"], permission_classes=[IsModeratorOrAdmin])
def claim(self, request, pk=None):
"""
Claim a photo submission for review with concurrency protection.
Uses select_for_update() to acquire a database row lock.
"""
from django.db import transaction, DatabaseError
from django.core.exceptions import ValidationError
with transaction.atomic():
try:
submission = PhotoSubmission.objects.select_for_update(nowait=True).get(pk=pk)
except PhotoSubmission.DoesNotExist:
return Response(
{"error": "Submission not found"},
status=status.HTTP_404_NOT_FOUND
)
except DatabaseError:
return Response(
{"error": "Submission is being claimed by another moderator. Please try again."},
status=status.HTTP_409_CONFLICT
)
if submission.status == "CLAIMED":
return Response(
{
"error": "Submission already claimed",
"claimed_by": submission.claimed_by.username if submission.claimed_by else None,
"claimed_at": submission.claimed_at.isoformat() if submission.claimed_at else None,
},
status=status.HTTP_409_CONFLICT
)
if submission.status != "PENDING":
return Response(
{"error": f"Cannot claim submission in {submission.status} state"},
status=status.HTTP_400_BAD_REQUEST
)
try:
submission.claim(user=request.user)
log_business_event(
logger,
event_type="submission_claimed",
message=f"PhotoSubmission {submission.id} claimed by {request.user.username}",
context={
"model": "PhotoSubmission",
"object_id": submission.id,
"claimed_by": request.user.username,
},
request=request,
)
return Response(self.get_serializer(submission).data)
except ValidationError as e:
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
@action(detail=True, methods=["post"], permission_classes=[IsModeratorOrAdmin])
def unclaim(self, request, pk=None):
"""Release claim on a photo submission."""
from django.core.exceptions import ValidationError
submission = self.get_object()
if submission.claimed_by != request.user and not request.user.is_staff:
return Response(
{"error": "Only the claiming moderator or an admin can unclaim"},
status=status.HTTP_403_FORBIDDEN
)
if submission.status != "CLAIMED":
return Response(
{"error": "Submission is not claimed"},
status=status.HTTP_400_BAD_REQUEST
)
try:
submission.unclaim(user=request.user)
log_business_event(
logger,
event_type="submission_unclaimed",
message=f"PhotoSubmission {submission.id} unclaimed by {request.user.username}",
context={
"model": "PhotoSubmission",
"object_id": submission.id,
"unclaimed_by": request.user.username,
},
request=request,
)
return Response(self.get_serializer(submission).data)
except ValidationError as e:
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
@action(detail=True, methods=["post"], permission_classes=[IsModeratorOrAdmin])
def approve(self, request, pk=None):
submission = self.get_object()
user = request.user
notes = request.data.get("notes", "")
try:
submission.approve(moderator=user, notes=notes)
return Response(self.get_serializer(submission).data)
except Exception as e:
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
@action(detail=True, methods=["post"], permission_classes=[IsModeratorOrAdmin])
def reject(self, request, pk=None):
submission = self.get_object()
user = request.user
notes = request.data.get("notes", "")
try:
submission.reject(moderator=user, notes=notes)
return Response(self.get_serializer(submission).data)
except Exception as e:
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
@action(detail=True, methods=["post"], permission_classes=[IsModeratorOrAdmin])
def escalate(self, request, pk=None):
submission = self.get_object()
user = request.user
notes = request.data.get("notes", "")
try:
submission.escalate(moderator=user, notes=notes)
return Response(self.get_serializer(submission).data)
except Exception as e:
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)

View File

@@ -4,3 +4,6 @@ class ReviewsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.reviews"
verbose_name = "User Reviews"
def ready(self):
import apps.reviews.signals

View File

@@ -0,0 +1,176 @@
# Generated by Django 5.1.6 on 2025-12-26 14:29
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="Review",
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 being reviewed")),
(
"rating",
models.PositiveSmallIntegerField(
choices=[(1, "1"), (2, "2"), (3, "3"), (4, "4"), (5, "5")],
db_index=True,
help_text="Rating from 1 to 5",
),
),
("text", models.TextField(blank=True, help_text="Review text (optional)")),
("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"),
),
(
"content_type",
models.ForeignKey(
help_text="Type of item being reviewed",
on_delete=django.db.models.deletion.CASCADE,
to="contenttypes.contenttype",
),
),
(
"user",
models.ForeignKey(
help_text="User who wrote the review",
on_delete=django.db.models.deletion.CASCADE,
related_name="reviews",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"verbose_name": "Review",
"verbose_name_plural": "Reviews",
"ordering": ["-created_at"],
"abstract": False,
},
),
migrations.CreateModel(
name="ReviewEvent",
fields=[
("pgh_id", models.AutoField(primary_key=True, serialize=False)),
("pgh_created_at", models.DateTimeField(auto_now_add=True)),
("pgh_label", models.TextField(help_text="The event label.")),
("id", models.BigIntegerField()),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("object_id", models.PositiveIntegerField(help_text="ID of the item being reviewed")),
(
"rating",
models.PositiveSmallIntegerField(
choices=[(1, "1"), (2, "2"), (3, "3"), (4, "4"), (5, "5")], help_text="Rating from 1 to 5"
),
),
("text", models.TextField(blank=True, help_text="Review text (optional)")),
("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"),
),
(
"content_type",
models.ForeignKey(
db_constraint=False,
help_text="Type of item being reviewed",
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="reviews.review",
),
),
(
"user",
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,
),
),
],
options={
"abstract": False,
},
),
migrations.AddIndex(
model_name="review",
index=models.Index(fields=["content_type", "object_id"], name="reviews_rev_content_627d80_idx"),
),
migrations.AddIndex(
model_name="review",
index=models.Index(fields=["rating"], name="reviews_rev_rating_2db6dd_idx"),
),
migrations.AlterUniqueTogether(
name="review",
unique_together={("user", "content_type", "object_id")},
),
pgtrigger.migrations.AddTrigger(
model_name="review",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "reviews_reviewevent" ("content_type_id", "created_at", "helpful_votes", "id", "is_public", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "rating", "text", "updated_at", "user_id") VALUES (NEW."content_type_id", NEW."created_at", NEW."helpful_votes", NEW."id", NEW."is_public", NEW."object_id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."rating", NEW."text", NEW."updated_at", NEW."user_id"); RETURN NULL;',
hash="72f23486e0f1db9f6f47e7cd42888c4d87a6a31b",
operation="INSERT",
pgid="pgtrigger_insert_insert_7a7c1",
table="reviews_review",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="review",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "reviews_reviewevent" ("content_type_id", "created_at", "helpful_votes", "id", "is_public", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "rating", "text", "updated_at", "user_id") VALUES (NEW."content_type_id", NEW."created_at", NEW."helpful_votes", NEW."id", NEW."is_public", NEW."object_id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."rating", NEW."text", NEW."updated_at", NEW."user_id"); RETURN NULL;',
hash="ca02efb281912450d6755ec9b07ebc998eabf421",
operation="UPDATE",
pgid="pgtrigger_update_update_b34c8",
table="reviews_review",
when="AFTER",
),
),
),
]

View File

@@ -0,0 +1,29 @@
from rest_framework import serializers
from .models import Review
from apps.accounts.serializers import UserSerializer
class ReviewSerializer(serializers.ModelSerializer):
user = UserSerializer(read_only=True)
class Meta:
model = Review
fields = [
"id",
"user",
"content_type",
"object_id",
"rating",
"text",
"is_public",
"helpful_votes",
"created_at",
"updated_at",
]
read_only_fields = ["id", "user", "helpful_votes", "created_at", "updated_at"]
def validate(self, data):
"""
Check that rating is between 1 and 5.
"""
# Rating is already validated by model field validation but explicit check is good
return data

View File

@@ -0,0 +1,30 @@
from django.db.models.signals import post_save, post_delete
from django.dispatch import receiver
from django.db.models import Avg
from .models import Review
@receiver(post_save, sender=Review)
@receiver(post_delete, sender=Review)
def update_average_rating(sender, instance, **kwargs):
"""
Update the average rating of the content object when a review is saved or deleted.
"""
content_object = instance.content_object
if not content_object:
# If content object doesn't exist (orphaned review?), skip
return
# Check if the content object has an 'average_rating' field
if not hasattr(content_object, 'average_rating'):
return
# Calculate new average
# We query the Review model filtering by content_type and object_id
avg_rating = Review.objects.filter(
content_type=instance.content_type,
object_id=instance.object_id
).aggregate(Avg('rating'))['rating__avg']
# Update field
content_object.average_rating = avg_rating or 0 # Default to 0 if no reviews
content_object.save(update_fields=['average_rating'])

View File

@@ -0,0 +1,10 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import ReviewViewSet
router = DefaultRouter()
router.register(r"reviews", ReviewViewSet, basename="review")
urlpatterns = [
path("", include(router.urls)),
]

View File

@@ -0,0 +1,27 @@
from rest_framework import viewsets, permissions, filters
from django_filters.rest_framework import DjangoFilterBackend
from .models import Review
from .serializers import ReviewSerializer
from apps.core.permissions import IsOwnerOrReadOnly
class ReviewViewSet(viewsets.ModelViewSet):
queryset = Review.objects.filter(is_public=True)
serializer_class = ReviewSerializer
permission_classes = [permissions.IsAuthenticatedOrReadOnly, IsOwnerOrReadOnly]
filter_backends = [DjangoFilterBackend, filters.OrderingFilter]
filterset_fields = ["content_type", "object_id", "rating", "user"]
ordering_fields = ["created_at", "rating", "helpful_votes"]
ordering = ["-created_at"]
def get_queryset(self):
# Users can see their own non-public reviews?
# Standard queryset is public only.
# But if we want authors to see their own pending/private reviews:
qs = Review.objects.filter(is_public=True)
if self.request.user.is_authenticated:
# Add user's own reviews even if not public (if that's a use case)
qs = qs | Review.objects.filter(user=self.request.user)
return qs.distinct()
def perform_create(self, serializer):
serializer.save(user=self.request.user)

View File

@@ -0,0 +1,169 @@
# Generated by Django 5.1.6 on 2025-12-26 15:57
import django.core.validators
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 = [
("pghistory", "0006_delete_aggregateevent"),
("rides", "0027_alter_company_options_alter_rankingsnapshot_options_and_more"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="RideCredit",
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)),
("count", models.PositiveIntegerField(default=1, help_text="Number of times ridden")),
(
"rating",
models.IntegerField(
blank=True,
help_text="Personal rating (1-5)",
null=True,
validators=[
django.core.validators.MinValueValidator(1),
django.core.validators.MaxValueValidator(5),
],
),
),
("first_ridden_at", models.DateField(blank=True, help_text="Date of first ride", null=True)),
("last_ridden_at", models.DateField(blank=True, help_text="Date of most recent ride", null=True)),
("notes", models.TextField(blank=True, help_text="Personal notes about the experience")),
(
"ride",
models.ForeignKey(
help_text="The ride that was ridden",
on_delete=django.db.models.deletion.CASCADE,
related_name="credits",
to="rides.ride",
),
),
(
"user",
models.ForeignKey(
help_text="User who rode the ride",
on_delete=django.db.models.deletion.CASCADE,
related_name="ride_credits",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"verbose_name": "Ride Credit",
"verbose_name_plural": "Ride Credits",
"ordering": ["-last_ridden_at", "-first_ridden_at", "-created_at"],
"abstract": False,
"unique_together": {("user", "ride")},
},
),
migrations.CreateModel(
name="RideCreditEvent",
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)),
("count", models.PositiveIntegerField(default=1, help_text="Number of times ridden")),
(
"rating",
models.IntegerField(
blank=True,
help_text="Personal rating (1-5)",
null=True,
validators=[
django.core.validators.MinValueValidator(1),
django.core.validators.MaxValueValidator(5),
],
),
),
("first_ridden_at", models.DateField(blank=True, help_text="Date of first ride", null=True)),
("last_ridden_at", models.DateField(blank=True, help_text="Date of most recent ride", null=True)),
("notes", models.TextField(blank=True, help_text="Personal notes about the experience")),
(
"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="rides.ridecredit",
),
),
(
"ride",
models.ForeignKey(
db_constraint=False,
help_text="The ride that was ridden",
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="rides.ride",
),
),
(
"user",
models.ForeignKey(
db_constraint=False,
help_text="User who rode the ride",
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="ridecredit",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "rides_ridecreditevent" ("count", "created_at", "first_ridden_at", "id", "last_ridden_at", "notes", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "rating", "ride_id", "updated_at", "user_id") VALUES (NEW."count", NEW."created_at", NEW."first_ridden_at", NEW."id", NEW."last_ridden_at", NEW."notes", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."rating", NEW."ride_id", NEW."updated_at", NEW."user_id"); RETURN NULL;',
hash="99d9f7e7134fbcb6f84a1966fe9539c8ccc22eee",
operation="INSERT",
pgid="pgtrigger_insert_insert_00439",
table="rides_ridecredit",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="ridecredit",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "rides_ridecreditevent" ("count", "created_at", "first_ridden_at", "id", "last_ridden_at", "notes", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "rating", "ride_id", "updated_at", "user_id") VALUES (NEW."count", NEW."created_at", NEW."first_ridden_at", NEW."id", NEW."last_ridden_at", NEW."notes", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."rating", NEW."ride_id", NEW."updated_at", NEW."user_id"); RETURN NULL;',
hash="1795a528b3b188da59c8cf60053df8eddb80904c",
operation="UPDATE",
pgid="pgtrigger_update_update_32a65",
table="rides_ridecredit",
when="AFTER",
),
),
),
]

View File

@@ -14,6 +14,7 @@ from .location import RideLocation
from .reviews import RideReview
from .rankings import RideRanking, RidePairComparison, RankingSnapshot
from .media import RidePhoto
from .credits import RideCredit
__all__ = [
# Primary models
@@ -24,6 +25,7 @@ __all__ = [
"RideLocation",
"RideReview",
"RidePhoto",
"RideCredit",
# Rankings
"RideRanking",
"RidePairComparison",

View File

@@ -0,0 +1,55 @@
from django.db import models
from django.conf import settings
from django.core.validators import MinValueValidator, MaxValueValidator
import pghistory
from apps.core.history import TrackedModel
@pghistory.track()
class RideCredit(TrackedModel):
"""
Represents a user's ride credit (a ride they have ridden).
Functions as a through-model for tracking which rides a user has experienced.
"""
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="ride_credits",
help_text="User who rode the ride",
)
ride = models.ForeignKey(
"rides.Ride",
on_delete=models.CASCADE,
related_name="credits",
help_text="The ride that was ridden",
)
# Credit Details
count = models.PositiveIntegerField(
default=1, help_text="Number of times ridden"
)
rating = models.IntegerField(
null=True,
blank=True,
validators=[MinValueValidator(1), MaxValueValidator(5)],
help_text="Personal rating (1-5)",
)
first_ridden_at = models.DateField(
null=True, blank=True, help_text="Date of first ride"
)
last_ridden_at = models.DateField(
null=True, blank=True, help_text="Date of most recent ride"
)
notes = models.TextField(
blank=True, help_text="Personal notes about the experience"
)
class Meta(TrackedModel.Meta):
verbose_name = "Ride Credit"
verbose_name_plural = "Ride Credits"
unique_together = ["user", "ride"]
ordering = ["-last_ridden_at", "-first_ridden_at", "-created_at"]
def __str__(self):
return f"{self.user} - {self.ride}"

View File

View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class SupportConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.support"
verbose_name = "Support"

View File

@@ -0,0 +1,54 @@
# Generated by Django 5.1.6 on 2025-12-26 14:34
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="Ticket",
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)),
("subject", models.CharField(max_length=255)),
("message", models.TextField()),
("email", models.EmailField(blank=True, help_text="Contact email", max_length=254)),
(
"status",
models.CharField(
choices=[("open", "Open"), ("in_progress", "In Progress"), ("closed", "Closed")],
db_index=True,
default="open",
max_length=20,
),
),
(
"user",
models.ForeignKey(
blank=True,
help_text="User who submitted the ticket (optional)",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="tickets",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"verbose_name": "Ticket",
"verbose_name_plural": "Tickets",
"ordering": ["-created_at"],
"abstract": False,
},
),
]

View File

@@ -0,0 +1,48 @@
from django.db import models
from django.conf import settings
from apps.core.history import TrackedModel
class Ticket(TrackedModel):
STATUS_OPEN = 'open'
STATUS_IN_PROGRESS = 'in_progress'
STATUS_CLOSED = 'closed'
STATUS_CHOICES = [
(STATUS_OPEN, 'Open'),
(STATUS_IN_PROGRESS, 'In Progress'),
(STATUS_CLOSED, 'Closed'),
]
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="tickets",
help_text="User who submitted the ticket (optional)"
)
subject = models.CharField(max_length=255)
message = models.TextField()
email = models.EmailField(help_text="Contact email", blank=True)
status = models.CharField(
max_length=20,
choices=STATUS_CHOICES,
default=STATUS_OPEN,
db_index=True
)
class Meta(TrackedModel.Meta):
verbose_name = "Ticket"
verbose_name_plural = "Tickets"
ordering = ["-created_at"]
def __str__(self):
return f"[{self.get_status_display()}] {self.subject}"
def save(self, *args, **kwargs):
# If user is set but email is empty, autofill from user
if self.user and not self.email:
self.email = self.user.email
super().save(*args, **kwargs)

View File

@@ -0,0 +1,27 @@
from rest_framework import serializers
from .models import Ticket
from apps.accounts.serializers import UserSerializer
class TicketSerializer(serializers.ModelSerializer):
user = UserSerializer(read_only=True)
class Meta:
model = Ticket
fields = [
"id",
"user",
"subject",
"message",
"email",
"status",
"created_at",
"updated_at",
]
read_only_fields = ["id", "status", "created_at", "updated_at", "user"]
def validate(self, data):
# Ensure email is provided if user is anonymous
request = self.context.get('request')
if request and not request.user.is_authenticated and not data.get('email'):
raise serializers.ValidationError({"email": "Email is required for guests."})
return data

View File

@@ -0,0 +1,10 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import TicketViewSet
router = DefaultRouter()
router.register(r"tickets", TicketViewSet, basename="ticket")
urlpatterns = [
path("", include(router.urls)),
]

View File

@@ -0,0 +1,32 @@
from rest_framework import viewsets, permissions, filters
from django_filters.rest_framework import DjangoFilterBackend
from .models import Ticket
from .serializers import TicketSerializer
class TicketViewSet(viewsets.ModelViewSet):
"""
Standard users/guests can CREATE.
Only Staff can LIST/RETRIEVE/UPDATE all.
Users can LIST/RETRIEVE their own.
"""
queryset = Ticket.objects.all()
serializer_class = TicketSerializer
permission_classes = [permissions.AllowAny] # We handle granular perms in get_queryset/perform_create
filter_backends = [DjangoFilterBackend, filters.OrderingFilter]
filterset_fields = ["status"]
ordering_fields = ["created_at", "status"]
ordering = ["-created_at"]
def get_queryset(self):
user = self.request.user
if user.is_staff:
return Ticket.objects.all()
if user.is_authenticated:
return Ticket.objects.filter(user=user)
return Ticket.objects.none() # Guests can't list tickets
def perform_create(self, serializer):
if self.request.user.is_authenticated:
serializer.save(user=self.request.user, email=self.request.user.email)
else:
serializer.save()

View File

@@ -114,6 +114,10 @@ LOCAL_APPS = [
"django_forwardemail", # New PyPI package for email service
"apps.moderation",
"apps.lists",
"apps.reviews",
"apps.media",
"apps.blog",
"apps.support",
]
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS

32
backend/ensure_admin.py Normal file
View File

@@ -0,0 +1,32 @@
import os
import sys
import django
sys.path.append(os.path.join(os.path.dirname(__file__)))
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "thrillwiki.settings")
django.setup()
from django.contrib.auth import get_user_model
User = get_user_model()
def ensure_admin():
username = "admin"
email = "admin@example.com"
password = "adminpassword"
if not User.objects.filter(username=username).exists():
print(f"Creating superuser {username}...")
User.objects.create_superuser(username=username, email=email, password=password, role="ADMIN")
print("Superuser created.")
else:
print(f"Superuser {username} already exists.")
u = User.objects.get(username=username)
if not u.is_staff or not u.is_superuser or u.role != 'ADMIN':
u.is_staff = True
u.is_superuser = True
u.role = 'ADMIN'
u.save()
print("Updated existing user to ADMIN/Superuser.")
if __name__ == "__main__":
ensure_admin()

View File

@@ -21,12 +21,7 @@ from apps.api.v1.accounts.serializers import (
UserProfileCreateInputSerializer,
UserProfileUpdateInputSerializer,
UserProfileOutputSerializer,
TopListCreateInputSerializer,
TopListUpdateInputSerializer,
TopListOutputSerializer,
TopListItemCreateInputSerializer,
TopListItemUpdateInputSerializer,
TopListItemOutputSerializer,
)
from tests.factories import (
@@ -480,35 +475,4 @@ class TestUserProfileUpdateInputSerializer(TestCase):
assert extra_kwargs.get("user", {}).get("read_only") is True
class TestTopListCreateInputSerializer(TestCase):
"""Tests for TopListCreateInputSerializer."""
def test__meta__fields__includes_all_fields(self):
"""Test Meta.fields is set to __all__."""
assert TopListCreateInputSerializer.Meta.fields == "__all__"
class TestTopListUpdateInputSerializer(TestCase):
"""Tests for TopListUpdateInputSerializer."""
def test__meta__user_read_only(self):
"""Test user field is read-only for updates."""
extra_kwargs = TopListUpdateInputSerializer.Meta.extra_kwargs
assert extra_kwargs.get("user", {}).get("read_only") is True
class TestTopListItemCreateInputSerializer(TestCase):
"""Tests for TopListItemCreateInputSerializer."""
def test__meta__fields__includes_all_fields(self):
"""Test Meta.fields is set to __all__."""
assert TopListItemCreateInputSerializer.Meta.fields == "__all__"
class TestTopListItemUpdateInputSerializer(TestCase):
"""Tests for TopListItemUpdateInputSerializer."""
def test__meta__top_list_not_read_only(self):
"""Test top_list field is not read-only for updates."""
extra_kwargs = TopListItemUpdateInputSerializer.Meta.extra_kwargs
assert extra_kwargs.get("top_list", {}).get("read_only") is False

966
source_docs/COMPONENTS.md Normal file
View File

@@ -0,0 +1,966 @@
# ThrillWiki Component Library
> Complete UI component documentation
---
## Table of Contents
1. [Layout Components](#layout-components)
2. [Navigation Components](#navigation-components)
3. [Display Components](#display-components)
4. [Form Components](#form-components)
5. [Feedback Components](#feedback-components)
6. [Data Display](#data-display)
7. [Entity Components](#entity-components)
8. [Specialty Components](#specialty-components)
---
## Component Architecture
```
Components
├── Layout
│ ├── Header
│ ├── Footer
│ ├── Sidebar
│ └── PageContainer
├── Navigation
│ ├── MainNav
│ ├── TabNav
│ ├── Breadcrumbs
│ └── Pagination
├── Display
│ ├── Card
│ ├── Badge
│ ├── Avatar
│ └── Image
├── Forms
│ ├── Input
│ ├── Select
│ ├── Checkbox
│ └── Button
├── Feedback
│ ├── Toast
│ ├── Alert
│ ├── Modal
│ └── Loading
├── Data
│ ├── Table
│ ├── List
│ ├── Stats
│ └── Charts
└── Entity
├── ParkCard
├── RideCard
├── ReviewCard
└── CreditCard
```
---
## Layout Components
### Header
The main navigation bar at the top of every page.
```
┌─────────────────────────────────────────────────────────────────┐
│ 🎢 ThrillWiki Parks Rides Companies 🔍 👤 🔔 │
└─────────────────────────────────────────────────────────────────┘
```
**Features:**
- Logo/brand link to homepage
- Primary navigation links
- Search button/input
- User menu (avatar dropdown)
- Notification bell
- Mobile hamburger menu
**Variants:**
- Default: Full navigation
- Minimal: Logo only (auth pages)
- Admin: Additional admin links
---
### Footer
Site-wide footer with links and information.
```
┌─────────────────────────────────────────────────────────────────┐
│ │
│ ThrillWiki Explore Community Legal │
│ The ultimate Parks Guidelines Terms │
│ theme park Rides Leaderboard Privacy │
│ database Companies Blog Contact │
│ Models Discord │
│ │
│ ───────────────────────────────────────────────────────────── │
│ © 2024 ThrillWiki [Twitter] [Discord] │
│ │
└─────────────────────────────────────────────────────────────────┘
```
**Sections:**
- Brand info
- Navigation columns
- Social links
- Copyright
---
### PageContainer
Wrapper component for consistent page layout.
```
┌─────────────────────────────────────────────────────────────────┐
│ HEADER │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ PAGE CONTENT │ │
│ │ │ │
│ │ max-width: 1280px │ │
│ │ padding: responsive │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
├─────────────────────────────────────────────────────────────────┤
│ FOOTER │
└─────────────────────────────────────────────────────────────────┘
```
**Props:**
| Prop | Type | Description |
|------|------|-------------|
| `maxWidth` | string | Container max width |
| `padding` | string | Content padding |
| `className` | string | Additional classes |
---
### Sidebar
Collapsible sidebar for settings and admin pages.
```
┌──────────────────┐
│ Section 1 │
│ ├── Item 1 │
│ ├── Item 2 ● │ ← Active
│ └── Item 3 │
│ │
│ Section 2 │
│ ├── Item 4 │
│ └── Item 5 │
│ │
│ [Collapse ◀] │
└──────────────────┘
```
---
## Navigation Components
### MainNav
Primary navigation in header.
```
┌────────────────────────────────────────────────────┐
│ Parks ▾ Rides ▾ Companies ▾ More ▾ │
└────────────────────────────────────────────────────┘
┌────────────────────┐
│ All Parks │
│ Parks Nearby │
│ ───────────── │
│ Add New Park │
└────────────────────┘
```
**Features:**
- Dropdown menus on hover/click
- Mobile drawer navigation
- Active state highlighting
- Keyboard navigation
---
### TabNav
Horizontal tab navigation within pages.
```
┌─────────────────────────────────────────────────────────────┐
│ Overview Rides Reviews Photos History │
│ ═════════ │
└─────────────────────────────────────────────────────────────┘
```
**Props:**
| Prop | Type | Description |
|------|------|-------------|
| `tabs` | Tab[] | Array of tab definitions |
| `activeTab` | string | Currently active tab |
| `onChange` | function | Tab change handler |
---
### Breadcrumbs
Hierarchical navigation path.
```
Home > Parks > Cedar Point > Steel Vengeance
```
**Features:**
- Clickable ancestors
- Current page (non-clickable)
- Truncation for long paths
- Schema.org markup
---
### Pagination
Navigate through paginated content.
```
┌─────────────────────────────────────────────────────┐
│ ◀ Previous 1 2 [3] 4 5 ... 12 Next ▶ │
└─────────────────────────────────────────────────────┘
```
**Props:**
| Prop | Type | Description |
|------|------|-------------|
| `currentPage` | number | Current page |
| `totalPages` | number | Total page count |
| `onPageChange` | function | Page change handler |
---
## Display Components
### Card
Versatile container for content.
```
┌─────────────────────────────────────┐
│ ┌─────────────────────────────┐ │
│ │ │ │
│ │ IMAGE │ │
│ │ │ │
│ └─────────────────────────────┘ │
│ │
│ Title │
│ Subtitle or description │
│ │
│ [Action 1] [Action 2] │
│ │
└─────────────────────────────────────┘
```
**Variants:**
| Variant | Description |
|---------|-------------|
| Default | Basic card with border |
| Elevated | Shadow for depth |
| Interactive | Hover effects, clickable |
| Glass | Frosted glass (dark mode) |
---
### Badge
Small label for status or categories.
```
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Operating │ │ Coaster │ │ New! │
└──────────────┘ └──────────────┘ └──────────────┘
(green) (blue) (accent)
```
**Variants:**
| Variant | Usage |
|---------|-------|
| Default | General labels |
| Success | Operating, approved |
| Warning | Under construction |
| Destructive | Closed, error |
| Outline | Secondary importance |
---
### Avatar
User or entity image.
```
┌─────┐ ┌─────┐ ┌─────┐
│ │ │ │ │ AB │
│ 👤 │ │ IMG │ │ │
│ │ │ │ │ │
└─────┘ └─────┘ └─────┘
Icon Image Initials
```
**Sizes:**
- `sm`: 24px
- `md`: 40px (default)
- `lg`: 64px
- `xl`: 96px
---
### Image
Optimized image display with loading states.
```
┌─────────────────────────────────────┐
│ │
│ Loading... ░░░░░░░░░░░░░ │
│ │
└─────────────────────────────────────┘
┌─────────────────────────────────────┐
│ │
│ Loaded Image │
│ │
└─────────────────────────────────────┘
```
**Features:**
- Lazy loading
- Blur placeholder
- Error fallback
- Responsive sizes
- CloudFlare Images integration
---
## Form Components
### Input
Text input field.
```
┌─────────────────────────────────────────────────────┐
│ Label │
├─────────────────────────────────────────────────────┤
│ Placeholder text... │
└─────────────────────────────────────────────────────┘
Hint text or validation message
```
**States:**
| State | Appearance |
|-------|------------|
| Default | Standard border |
| Focused | Primary ring |
| Error | Red border + message |
| Disabled | Muted, non-interactive |
**Types:**
- Text, Email, Password, Number
- Search (with icon)
- Textarea (multiline)
---
### Select
Dropdown selection.
```
┌─────────────────────────────────────────────────────┐
│ Select an option... ▾ │
└─────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────┐
│ Option 1 │
│ Option 2 ✓ │
│ Option 3 │
│ ─────────────────────────── │
│ Group Header │
│ Option 4 │
│ Option 5 │
└─────────────────────────────────────────────────────┘
```
**Features:**
- Searchable options
- Option groups
- Multi-select variant
- Clear selection
---
### Checkbox
Boolean or multi-selection.
```
☐ Unchecked option
☑ Checked option
☐ Unchecked option
```
**Variants:**
- Single checkbox
- Checkbox group
- Indeterminate state
---
### Radio
Single selection from options.
```
○ Option 1
● Option 2 (selected)
○ Option 3
```
---
### Switch
Toggle between two states.
```
Off ○──────● On
●──────○
```
---
### Button
Clickable action trigger.
```
┌────────────────┐ ┌────────────────┐ ┌────────────────┐
│ Primary │ │ Secondary │ │ Outline │
└────────────────┘ └────────────────┘ └────────────────┘
┌────────────────┐ ┌────────────────┐ ┌────────────────┐
│ Ghost │ │ Destructive │ │ Loading │
└────────────────┘ └────────────────┘ └────────────────┘
```
**Variants:**
| Variant | Usage |
|---------|-------|
| Primary | Main actions |
| Secondary | Secondary actions |
| Outline | Bordered, less emphasis |
| Ghost | Minimal, icon buttons |
| Destructive | Delete, dangerous |
| Link | Text-only, inline |
**Sizes:**
- `sm`: Compact
- `default`: Standard
- `lg`: Large
- `icon`: Square, icon-only
---
### DatePicker
Date selection component.
```
┌─────────────────────────────────────────────────────┐
│ 📅 Select date... │
└─────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────┐
│ ◀ December 2024 ▶ │
├─────────────────────────────────────────────────────┤
│ Su Mo Tu We Th Fr Sa │
│ 1 2 3 4 5 6 7 │
│ 8 9 10 11 12 13 14 │
│ 15 16 17 18 19 [20] 21 │
│ 22 23 24 25 26 27 28 │
│ 29 30 31 │
└─────────────────────────────────────────────────────┘
```
**Features:**
- Month/year navigation
- Date range selection
- Disabled dates
- Date precision selector
---
### Slider
Range value selection.
```
0 100
●───────────────────●────────────────────────────●
50
```
**Props:**
| Prop | Type | Description |
|------|------|-------------|
| `min` | number | Minimum value |
| `max` | number | Maximum value |
| `step` | number | Value increment |
| `value` | number | Current value |
---
## Feedback Components
### Toast
Temporary notification message.
```
┌─────────────────────────────────────────────────────┐
│ ✓ Changes saved successfully ✕ │
└─────────────────────────────────────────────────────┘
```
**Variants:**
| Variant | Usage |
|---------|-------|
| Default | General info |
| Success | Positive confirmation |
| Warning | Caution notice |
| Error | Error occurred |
---
### Alert
Inline notification.
```
┌─────────────────────────────────────────────────────┐
│ ⚠️ Warning │
│ This action cannot be undone. │
└─────────────────────────────────────────────────────┘
```
**Variants:**
- Info, Success, Warning, Error
---
### Modal / Dialog
Overlay for focused content.
```
┌─────────────────────────────────────────────────────┐
│ │
│ ┌───────────────────────────────────────────┐ │
│ │ Dialog Title ✕ │ │
│ ├───────────────────────────────────────────┤ │
│ │ │ │
│ │ Dialog content goes here │ │
│ │ │ │
│ ├───────────────────────────────────────────┤ │
│ │ [Cancel] [Confirm] │ │
│ └───────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────┘
```
**Types:**
- Alert Dialog (confirmation)
- Form Dialog (input)
- Info Dialog (display)
- Full-screen Dialog
---
### Loading States
Various loading indicators.
**Spinner:**
```
◠◡◠
```
**Skeleton:**
```
┌─────────────────────────────────────────────────────┐
│ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │
│ ░░░░░░░░░░░░░░░░ │
│ ░░░░░░░░░░░░░░░░░░░░░░░░ │
└─────────────────────────────────────────────────────┘
```
**Progress:**
```
[████████████░░░░░░░░] 60%
```
---
### Empty State
No content placeholder.
```
┌─────────────────────────────────────────────────────┐
│ │
│ 🎢 │
│ │
│ No rides found │
│ Try adjusting your filters │
│ │
│ [Clear Filters] │
│ │
└─────────────────────────────────────────────────────┘
```
---
## Data Display
### Table
Tabular data display.
```
┌────────────────┬────────────────┬────────────────┬────────────┐
│ Name │ Type │ Status │ Actions │
├────────────────┼────────────────┼────────────────┼────────────┤
│ Cedar Point │ Theme Park │ Operating │ [View] │
│ Six Flags │ Theme Park │ Operating │ [View] │
│ Dreamworld │ Theme Park │ Operating │ [View] │
└────────────────┴────────────────┴────────────────┴────────────┘
```
**Features:**
- Sortable columns
- Row selection
- Pagination
- Filtering
- Column resizing
---
### Stats Card
Metric display.
```
┌─────────────────────────┐
│ Total Parks │
│ ──────────── │
│ 1,234 │
│ ↑ 12% from last month │
└─────────────────────────┘
```
---
### Rating Display
Star rating visualization.
```
★★★★☆ 4.2 (156 reviews)
```
**Props:**
| Prop | Type | Description |
|------|------|-------------|
| `rating` | number | 0-5 rating value |
| `count` | number | Review count |
| `size` | string | Star size |
---
## Entity Components
### ParkCard
Park preview card.
```
┌─────────────────────────────────────┐
│ ┌─────────────────────────────┐ │
│ │ │ │
│ │ PARK IMAGE │ │
│ │ │ │
│ └─────────────────────────────┘ │
│ │
│ Cedar Point │
│ 📍 Sandusky, Ohio, USA │
│ 🎢 72 rides ★ 4.8 (156) │
│ │
│ [Operating] │
│ │
└─────────────────────────────────────┘
```
**Displays:**
- Park image
- Name
- Location
- Ride count
- Rating
- Status badge
---
### RideCard
Ride preview card.
```
┌─────────────────────────────────────┐
│ ┌─────────────────────────────┐ │
│ │ │ │
│ │ RIDE IMAGE │ │
│ │ │ │
│ └─────────────────────────────┘ │
│ │
│ Steel Vengeance │
│ Cedar Point │
│ ⚡ 121 km/h 📏 62.5m │
│ │
│ [Coaster] [Operating] │
│ │
└─────────────────────────────────────┘
```
**Displays:**
- Ride image
- Name
- Park name
- Key specs (speed, height)
- Category & status badges
---
### ReviewCard
User review display.
```
┌─────────────────────────────────────────────────────┐
│ ┌─────┐ username ★★★★★ │
│ │ │ Reviewed 2 days ago │
│ └─────┘ │
│ │
│ "Amazing coaster! The first drop is incredible │
│ and the airtime moments are relentless." │
│ │
│ 👍 24 👎 2 [Reply] [Report] │
│ │
└─────────────────────────────────────────────────────┘
```
---
### CreditCard
Ride credit display.
```
┌─────────────────────────────────────────────────────┐
│ ┌─────────────┐ Steel Vengeance │
│ │ │ Cedar Point │
│ │ IMG │ │
│ │ │ ×12 rides │
│ └─────────────┘ Last: August 2024 │
│ │
│ [] 12 [+] [Edit] │
│ │
└─────────────────────────────────────────────────────┘
```
---
## Specialty Components
### UnitDisplay
Displays values with unit conversion.
```
Speed: 121 km/h (75 mph)
or
Speed: 75 mph
```
**Types:**
- Speed (km/h, mph)
- Distance (m, ft)
- Height (m, ft)
- Weight (kg, lbs)
---
### Map
Interactive Leaflet map.
```
┌─────────────────────────────────────────────────────┐
│ │
│ 📍 │
│ 📍 📍 │
│ 📍 │
│ 📍 │
│ 📍 │
│ │
│ [][+] © OpenStreetMap │
└─────────────────────────────────────────────────────┘
```
**Features:**
- Marker clusters
- Custom markers
- Popups
- Zoom controls
- Full-screen toggle
---
### Timeline
Chronological event display.
```
├── 2024 ─── Renovation completed
├── 2022 ─── Major refurbishment
├── 2018 ─── Original opening
```
---
### Diff Viewer
Side-by-side comparison.
```
┌────────────────────────┬────────────────────────┐
│ BEFORE │ AFTER │
├────────────────────────┼────────────────────────┤
│ Name: Old Name │ Name: New Name ← │
│ Status: Closed │ Status: Closed │
│ Rides: 50 │ Rides: 52 ← │
└────────────────────────┴────────────────────────┘
```
---
### ImageGallery
Photo gallery with lightbox.
```
┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐
│ │ │ │ │ │ │ │
│ 1 │ │ 2 │ │ 3 │ │ +24 │
│ │ │ │ │ │ │ │
└────────┘ └────────┘ └────────┘ └────────┘
```
**Features:**
- Grid layout
- Lightbox view
- Navigation (prev/next)
- Zoom
- Download
---
### SearchAutocomplete
Search with suggestions.
```
┌─────────────────────────────────────────────────────┐
│ 🔍 cedar │
└─────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────┐
│ 🏰 Cedar Point Park │
│ 🎢 Cedar Creek Mine Ride Ride │
│ 🏢 Cedar Fair Company │
└─────────────────────────────────────────────────────┘
```
---
### Tooltip
Hover information.
```
┌─────────────────────────┐
What is RMC? ───────│ Rocky Mountain │
│ Construction: A │
│ manufacturer known │
│ for hybrid coasters │
└─────────────────────────┘
```
---
### HoverCard
Rich preview on hover.
```
┌─────────────────────────┐
│ ┌─────────────────┐ │
│ │ IMAGE │ │
Cedar Point ────────────│ └─────────────────┘ │
│ Cedar Point │
│ Sandusky, Ohio │
│ 🎢 72 rides ★ 4.8 │
│ │
│ [View Park →] │
└─────────────────────────┘
```
---
## Related Documentation
- [Site Overview](./SITE_OVERVIEW.md)
- [Design System](./DESIGN_SYSTEM.md)
- [Pages](./PAGES.md)
- [User Flows](./USER_FLOWS.md)

View File

@@ -0,0 +1,495 @@
# ThrillWiki Design System
> Visual identity, colors, typography, and styling guidelines
---
## Brand Identity
### Logo & Name
- **Name**: ThrillWiki (with optional "(Beta)" suffix)
- **Tagline**: The Ultimate Theme Park Database
- **Personality**: Enthusiastic, trustworthy, community-driven
### Visual Style
- Modern and clean
- Subtle gradients and glow effects
- Smooth animations
- Card-based layouts
- Generous whitespace
---
## Color System
### Light Mode Palette
```
┌─────────────────────────────────────────────────────────────┐
│ LIGHT MODE │
├─────────────────────────────────────────────────────────────┤
│ │
│ Background Foreground Primary │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ │ │ │ │ │ │
│ │ White │ │ Dark │ │ Blue │ │
│ │ #FFFFFF │ │ #1A1A2E │ │ #3B82F6 │ │
│ │ │ │ │ │ │ │
│ └──────────┘ └──────────┘ └──────────┘ │
│ │
│ Secondary Muted Accent │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ │ │ │ │ │ │
│ │ Light │ │ Gray │ │ Cyan │ │
│ │ Gray │ │ Text │ │ Glow │ │
│ │ │ │ │ │ │ │
│ └──────────┘ └──────────┘ └──────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
```
### Dark Mode Palette
```
┌─────────────────────────────────────────────────────────────┐
│ DARK MODE │
├─────────────────────────────────────────────────────────────┤
│ │
│ Background Foreground Primary │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │░░░░░░░░░░│ │ │ │▓▓▓▓▓▓▓▓▓▓│ │
│ │░ Dark ░░│ │ Light │ │▓ Blue ▓▓│ │
│ │░ Navy ░░│ │ Gray │ │▓ Glow ▓▓│ │
│ │░░░░░░░░░░│ │ │ │▓▓▓▓▓▓▓▓▓▓│ │
│ └──────────┘ └──────────┘ └──────────┘ │
│ │
│ Secondary Muted Accent │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │▒▒▒▒▒▒▒▒▒▒│ │ │ │▓▓▓▓▓▓▓▓▓▓│ │
│ │▒ Slate ▒▒│ │ Muted │ │▓ Cyan ▓▓│ │
│ │▒ Blue ▒▒│ │ Gray │ │▓ Accent▓▓│ │
│ │▒▒▒▒▒▒▒▒▒▒│ │ │ │▓▓▓▓▓▓▓▓▓▓│ │
│ └──────────┘ └──────────┘ └──────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
```
### Semantic Colors
| Token | Purpose | Light | Dark |
|-------|---------|-------|------|
| `--background` | Page background | White | Dark navy |
| `--foreground` | Primary text | Dark gray | Light gray |
| `--primary` | Buttons, links | Blue | Blue |
| `--secondary` | Secondary UI | Light gray | Slate |
| `--muted` | Disabled, hints | Gray | Muted gray |
| `--accent` | Highlights | Cyan | Cyan |
| `--destructive` | Errors, delete | Red | Red |
| `--success` | Confirmations | Green | Green |
| `--warning` | Alerts | Amber | Amber |
### Gradients
| Name | Usage |
|------|-------|
| Primary Gradient | Hero sections, featured cards |
| Glow Effect | Hover states, focus rings |
| Subtle Gradient | Backgrounds, depth |
---
## Typography
### Font Family
```
Primary Font: Inter (Sans-Serif)
├── Weights: 400, 500, 600, 700
├── Used for: All UI text
└── Fallback: system-ui, sans-serif
```
### Type Scale
| Size | Name | Usage |
|------|------|-------|
| 48px | Display | Hero headlines |
| 36px | H1 | Page titles |
| 30px | H2 | Section headers |
| 24px | H3 | Card titles |
| 20px | H4 | Subheadings |
| 16px | Body | Default text |
| 14px | Small | Secondary text |
| 12px | Caption | Labels, hints |
### Text Styles
```
┌────────────────────────────────────────┐
│ HEADING STYLES │
├────────────────────────────────────────┤
│ │
│ Page Title │
│ ══════════════════════ │
│ 36px · Bold · Foreground │
│ │
│ Section Header │
│ ──────────────── │
│ 24px · Semibold · Foreground │
│ │
│ Card Title │
│ 18px · Medium · Foreground │
│ │
│ Body Text │
│ 16px · Regular · Muted Foreground │
│ │
│ Caption │
│ 12px · Regular · Muted │
│ │
└────────────────────────────────────────┘
```
---
## Spacing System
### Base Unit
- 4px base unit
- All spacing is multiples of 4
### Spacing Scale
| Token | Size | Common Usage |
|-------|------|--------------|
| `space-1` | 4px | Tight gaps |
| `space-2` | 8px | Icon gaps |
| `space-3` | 12px | Small padding |
| `space-4` | 16px | Default padding |
| `space-6` | 24px | Section gaps |
| `space-8` | 32px | Large gaps |
| `space-12` | 48px | Section margins |
| `space-16` | 64px | Page sections |
---
## Border Radius
| Token | Size | Usage |
|-------|------|-------|
| `rounded-sm` | 4px | Small elements |
| `rounded` | 8px | Buttons, inputs |
| `rounded-lg` | 12px | Cards |
| `rounded-xl` | 16px | Large cards |
| `rounded-full` | 9999px | Pills, avatars |
---
## Shadows
### Light Mode Shadows
```
┌─────────────────────────────────────────┐
│ SHADOW LEVELS │
├─────────────────────────────────────────┤
│ │
│ ┌───────────────┐ Shadow-sm │
│ │ │ Subtle, close │
│ │ Card │ │
│ │ │ │
│ └───────────────┘ │
│ ░░░░░░░░░░░░░░░ │
│ │
│ ┌───────────────┐ Shadow-md │
│ │ │ Default cards │
│ │ Card │ │
│ │ │ │
│ └───────────────┘ │
│ ░░░░░░░░░░░░░░░░░ │
│ │
│ ┌───────────────┐ Shadow-lg │
│ │ │ Elevated, modals │
│ │ Card │ │
│ │ │ │
│ └───────────────┘ │
│ ░░░░░░░░░░░░░░░░░░░ │
│ │
└─────────────────────────────────────────┘
```
### Glow Effects (Dark Mode)
| Effect | Usage |
|--------|-------|
| Primary Glow | Focused inputs, active buttons |
| Accent Glow | Hover states on cards |
| Subtle Glow | Background depth |
---
## Animation
### Timing Functions
| Name | Curve | Usage |
|------|-------|-------|
| `ease-smooth` | cubic-bezier(0.4, 0, 0.2, 1) | Most transitions |
| `ease-bounce` | cubic-bezier(0.68, -0.55, 0.265, 1.55) | Playful elements |
| `ease-out` | cubic-bezier(0, 0, 0.2, 1) | Exits |
### Duration
| Speed | Duration | Usage |
|-------|----------|-------|
| Fast | 150ms | Hover states |
| Normal | 200ms | Default transitions |
| Slow | 300ms | Page transitions |
| Slower | 500ms | Complex animations |
### Common Animations
```
┌─────────────────────────────────────────┐
│ ANIMATION PATTERNS │
├─────────────────────────────────────────┤
│ │
│ Fade In │
│ ┌───┐ → ┌───┐ → ┌───┐ │
│ │ ░ │ │ ▒ │ │ █ │ │
│ └───┘ └───┘ └───┘ │
│ 0% 50% 100% │
│ │
│ Slide Up │
│ ┌───┐ │
│ │ │ ↑ │
│ └───┘ │
│ ↑ │
│ ┌───┐ │
│ │ █ │ │
│ └───┘ │
│ │
│ Scale │
│ ┌─┐ → ┌───┐ → ┌─────┐ │
│ │ │ │ │ │ │ │
│ └─┘ └───┘ └─────┘ │
│ 95% 100% 100% │
│ │
└─────────────────────────────────────────┘
```
---
## Components Overview
### Button Variants
```
┌─────────────────────────────────────────────────────────────┐
│ BUTTON STYLES │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌────────────┐ Primary (Default) │
│ │ Button │ Blue background, white text │
│ └────────────┘ │
│ │
│ ┌────────────┐ Secondary │
│ │ Button │ Gray background, dark text │
│ └────────────┘ │
│ │
│ ┌────────────┐ Outline │
│ │ Button │ Border only, transparent background │
│ └────────────┘ │
│ │
│ ┌────────────┐ Ghost │
│ │ Button │ No border, transparent background │
│ └────────────┘ │
│ │
│ ┌────────────┐ Destructive │
│ │ Delete │ Red background, for dangerous actions │
│ └────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
```
### Card Styles
```
┌─────────────────────────────────────────────────────────────┐
│ CARD VARIANTS │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────┐ Default Card │
│ │ │ White/dark background │
│ │ Content goes here │ Subtle border │
│ │ │ Rounded corners │
│ └─────────────────────────┘ │
│ │
│ ┌─────────────────────────┐ Interactive Card │
│ │ ┌─────────────────────┐ │ Hover effect │
│ │ │ Image │ │ Cursor pointer │
│ │ └─────────────────────┘ │ Scale on hover │
│ │ Title │ │
│ │ Description │ │
│ └─────────────────────────┘ │
│ │
│ ┌─────────────────────────┐ Glass Card (Dark Mode) │
│ │░░░░░░░░░░░░░░░░░░░░░░░░░│ Semi-transparent │
│ │░ Frosted glass effect ░░│ Backdrop blur │
│ │░░░░░░░░░░░░░░░░░░░░░░░░░│ Subtle glow │
│ └─────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
```
### Input States
```
┌─────────────────────────────────────────────────────────────┐
│ INPUT STATES │
├─────────────────────────────────────────────────────────────┤
│ │
│ Default │
│ ┌─────────────────────────────────────┐ │
│ │ Placeholder text... │ │
│ └─────────────────────────────────────┘ │
│ │
│ Focused │
│ ╔═════════════════════════════════════╗ ← Primary ring │
│ ║ Input text ║ │
│ ╚═════════════════════════════════════╝ │
│ │
│ Error │
│ ┌─────────────────────────────────────┐ ← Red border │
│ │ Invalid input │ │
│ └─────────────────────────────────────┘ │
│ ⚠ Error message appears here │
│ │
│ Disabled │
│ ┌─────────────────────────────────────┐ ← Muted colors │
│ │ Cannot edit │ │
│ └─────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
```
---
## Layout Patterns
### Page Layout
```
┌───────────────────────────────────────────────────────────────┐
│ HEADER │
│ Logo Navigation Search User Menu │
├───────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ MAIN CONTENT │ │
│ │ │ │
│ │ Max Width: 1280px │ │
│ │ Padding: 16-24px (responsive) │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
├───────────────────────────────────────────────────────────────┤
│ FOOTER │
│ Links Social Copyright │
└───────────────────────────────────────────────────────────────┘
```
### Grid System
```
Desktop (1024px+) Tablet (768px+) Mobile (<768px)
┌───┬───┬───┬───┐ ┌────┬────┐ ┌────────┐
│ │ │ │ │ │ │ │ │ │
│ 1 │ 2 │ 3 │ 4 │ │ 1 │ 2 │ │ 1 │
│ │ │ │ │ │ │ │ │ │
├───┼───┼───┼───┤ ├────┼────┤ ├────────┤
│ │ │ │ │ │ │ │ │ │
│ 5 │ 6 │ 7 │ 8 │ │ 3 │ 4 │ │ 2 │
│ │ │ │ │ │ │ │ │ │
└───┴───┴───┴───┘ └────┴────┘ └────────┘
4 columns 2 columns 1 column
```
---
## Icons
### Icon Library
- **Lucide React** - Primary icon set
- Consistent 24px default size
- 1.5px stroke width
- Matches text color by default
### Common Icons
| Icon | Usage |
|------|-------|
| Search | Search functionality |
| Menu | Mobile navigation |
| User | Profile, account |
| Heart | Favorites, likes |
| Star | Ratings, reviews |
| Map Pin | Location features |
| Calendar | Dates, schedules |
| Camera | Photo uploads |
| Edit | Edit actions |
| Trash | Delete actions |
| Check | Success, completion |
| X | Close, cancel |
| ChevronRight | Navigation, expand |
| ExternalLink | External links |
---
## Responsive Breakpoints
| Breakpoint | Width | Target |
|------------|-------|--------|
| `sm` | 640px | Large phones |
| `md` | 768px | Tablets |
| `lg` | 1024px | Small laptops |
| `xl` | 1280px | Desktops |
| `2xl` | 1536px | Large screens |
---
## Accessibility Guidelines
### Color Contrast
- Normal text: 4.5:1 minimum
- Large text: 3:1 minimum
- Interactive elements: 3:1 minimum
### Focus States
- Visible focus ring on all interactive elements
- Primary color ring (2px offset)
- Never remove focus outlines
### Motion
- Respect `prefers-reduced-motion`
- Provide alternatives for animations
- No auto-playing videos
---
## Dark Mode Considerations
1. **Reduce contrast** - Pure white (#FFF) is too harsh; use off-white
2. **Elevate with lightness** - Higher elements are slightly lighter
3. **Subtle borders** - Use semi-transparent borders
4. **Glow effects** - Replace shadows with subtle glows
5. **Image dimming** - Slightly reduce image brightness
---
## Related Documentation
- [Site Overview](./SITE_OVERVIEW.md)
- [Pages Guide](./PAGES.md)
- [Components](./COMPONENTS.md)
- [User Flows](./USER_FLOWS.md)

1057
source_docs/PAGES.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,244 @@
# ThrillWiki Site Overview
> The Ultimate Theme Park & Roller Coaster Database
ThrillWiki is a comprehensive, community-driven platform for theme park enthusiasts to discover, document, and celebrate the world's attractions.
---
## Mission Statement
To be the definitive source for accurate, community-verified information about theme parks, rides, and the companies that create them.
---
## Site Map
```
ThrillWiki
├── 🏠 Homepage
│ ├── Hero Search
│ ├── Discovery Tabs (11 categories)
│ └── Recent Changes Feed
├── 🎢 Parks
│ ├── /parks - All Parks Listing
│ ├── /parks/nearby - Location-Based Discovery
│ └── /parks/{slug} - Individual Park Pages
│ ├── Overview Tab
│ ├── Rides Tab
│ ├── Reviews Tab
│ ├── Photos Tab
│ └── History Tab
├── 🎠 Rides
│ ├── /rides - All Rides Listing
│ └── /parks/{park}/rides/{ride} - Individual Ride Pages
│ ├── Overview Tab
│ ├── Specifications Tab
│ ├── Reviews Tab
│ ├── Photos Tab
│ └── History Tab
├── 🏭 Companies
│ ├── /manufacturers - Ride Manufacturers
│ ├── /designers - Ride Designers
│ ├── /operators - Park Operators
│ └── /owners - Property Owners
├── 📋 Ride Models
│ └── /ride-models/{slug} - Standard Ride Designs
├── 🔍 Search
│ └── /search - Global Search with Filters
├── 👤 User Features
│ ├── /auth - Sign In / Sign Up
│ ├── /profile/{username} - Public Profiles
│ ├── /settings - Account Settings
│ ├── /my-credits - Ride Credits Dashboard
│ └── /my-lists - Personal Rankings
├── ✏️ Contribution
│ ├── /submit/* - Content Submission Forms
│ └── /my-submissions - Submission History
├── 🛡️ Moderation
│ ├── /moderation - Queue Dashboard
│ └── /moderation/{id} - Review Interface
├── ⚙️ Admin
│ ├── /admin - Dashboard
│ ├── /admin/users - User Management
│ ├── /admin/monitoring - System Health
│ └── /admin/errors - Error Tracking
└── 📄 Static Pages
├── /terms - Terms of Service
├── /privacy - Privacy Policy
├── /guidelines - Community Guidelines
├── /contact - Contact Form
└── /blog - News & Updates
```
---
## Core Features
### 1. Discovery & Exploration
| Feature | Description |
|---------|-------------|
| **Global Search** | Find any park, ride, or company instantly |
| **Parks Nearby** | Location-based discovery with interactive map |
| **Advanced Filters** | Filter by type, status, location, specifications |
| **Discovery Tabs** | 11 curated categories on homepage |
| **Trending Content** | See what's popular in the community |
### 2. Content & Information
| Feature | Description |
|---------|-------------|
| **Detailed Park Pages** | Complete information, photos, reviews |
| **Comprehensive Ride Specs** | Technical details in user's preferred units |
| **Company Profiles** | Manufacturers, designers, operators |
| **Ride Models** | Standard designs with all installations |
| **Photo Galleries** | Community-uploaded imagery |
| **Historical Records** | Track changes over time |
### 3. Community Features
| Feature | Description |
|---------|-------------|
| **Reviews & Ratings** | Share experiences with star ratings |
| **Ride Credits** | Log rides you've experienced |
| **Personal Lists** | Create and share rankings |
| **Leaderboards** | Recognition for top contributors |
| **Badges** | Achievement system |
### 4. Contribution System
| Feature | Description |
|---------|-------------|
| **Submit New Content** | Add parks, rides, companies |
| **Edit Existing** | Suggest improvements |
| **Photo Uploads** | Share your images |
| **Moderation Queue** | Quality review process |
| **Version History** | Track all changes |
---
## User Roles
### Anonymous Visitors
- Browse all public content
- Use search and filters
- View photos and reviews
- See ride specifications
### Registered Users
- All anonymous features, plus:
- Write reviews and ratings
- Log ride credits
- Create personal lists
- Upload photos
- Submit new content
- Edit existing content
### Contributors
- All registered features, plus:
- Higher submission trust
- Skip moderation for minor edits
- Recognition on leaderboards
### Moderators
- All contributor features, plus:
- Access moderation queue
- Approve/reject submissions
- Review photos
- Manage content quality
### Administrators
- All moderator features, plus:
- User management
- System configuration
- Error monitoring
- Database maintenance
---
## Key User Journeys
### Discovery Journey
```
Homepage → Search/Browse → Park/Ride Page → Explore Related Content
```
### Engagement Journey
```
View Content → Sign Up → Write Review → Log Credit → Create List
```
### Contribution Journey
```
Find Missing Info → Submit Edit → Await Moderation → See Changes Live
```
### Recognition Journey
```
Contribute Content → Earn Points → Climb Leaderboard → Earn Badges
```
---
## Content Statistics (Typical)
| Content Type | Description |
|--------------|-------------|
| Parks | Theme parks, amusement parks, water parks worldwide |
| Rides | Roller coasters, flat rides, water rides, dark rides |
| Companies | Manufacturers, designers, operators, owners |
| Reviews | User experiences and ratings |
| Photos | Community-uploaded images |
| Ride Credits | Personal ride experiences logged |
---
## Platform Availability
| Platform | Status |
|----------|--------|
| Web (Desktop) | ✅ Full Experience |
| Web (Tablet) | ✅ Responsive Design |
| Web (Mobile) | ✅ Touch-Optimized |
| Native Apps | ❌ Not Available |
---
## Accessibility
- Full keyboard navigation
- Screen reader compatible
- High contrast support
- Respects reduced motion preferences
- Touch-friendly on mobile
---
## Internationalization
| Feature | Status |
|---------|--------|
| Language | English only |
| Units | Metric/Imperial toggle |
| Dates | Localized formatting |
| Currency | Not applicable |
---
## Next Steps
- [Design System](./DESIGN_SYSTEM.md) - Visual identity and styling
- [Pages Guide](./PAGES.md) - Detailed page documentation
- [Components](./COMPONENTS.md) - UI component library
- [User Flows](./USER_FLOWS.md) - Journey diagrams

881
source_docs/USER_FLOWS.md Normal file
View File

@@ -0,0 +1,881 @@
# ThrillWiki User Flows
> Visual diagrams of user journeys and system interactions
---
## Table of Contents
1. [Discovery Flows](#discovery-flows)
2. [Authentication Flows](#authentication-flows)
3. [Content Viewing Flows](#content-viewing-flows)
4. [Contribution Flows](#contribution-flows)
5. [Engagement Flows](#engagement-flows)
6. [Moderation Flows](#moderation-flows)
7. [Admin Flows](#admin-flows)
---
## Discovery Flows
### Homepage Discovery Journey
```
┌─────────────────────────────────────────────────────────────────┐
│ │
│ USER ARRIVES │
│ │ │
│ ┌───────────────┼───────────────┐ │
│ ▼ ▼ ▼ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Search │ │ Discovery│ │ Recent │ │
│ │ Bar │ │ Tabs │ │ Changes │ │
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Instant │ │ Content │ │ Click │ │
│ │ Results │ │ Grid │ │ Item │ │
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
│ │ │ │ │
│ └──────────────┼──────────────┘ │
│ ▼ │
│ ┌──────────┐ │
│ │ Entity │ │
│ │ Page │ │
│ └──────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
```
### Search Flow
```
┌─────────────────────────────────────────────────────────────────┐
│ │
│ User Types Query │
│ │ │
│ ▼ │
│ ┌───────────────────┐ │
│ │ Debounce Input │ │
│ │ (300ms) │ │
│ └─────────┬─────────┘ │
│ │ │
│ ▼ │
│ ┌───────────────────┐ │
│ │ Search API │ │
│ │ Called │ │
│ └─────────┬─────────┘ │
│ │ │
│ ┌───────┴───────┐ │
│ ▼ ▼ │
│ ┌─────────┐ ┌─────────┐ │
│ │ Results │ │ No │ │
│ │ Found │ │ Results │ │
│ └────┬────┘ └────┬────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌─────────┐ ┌─────────┐ │
│ │ Display │ │ Empty │ │
│ │ List │ │ State │ │
│ └────┬────┘ └─────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────┐ │
│ │ User Selects Result │ │
│ │ (Click or Keyboard) │ │
│ └───────────┬─────────────┘ │
│ │ │
│ ▼ │
│ Navigate to Page │
│ │
└─────────────────────────────────────────────────────────────────┘
```
### Parks Nearby Flow
```
┌─────────────────────────────────────────────────────────────────┐
│ │
│ User Opens Nearby Page │
│ │ │
│ ▼ │
│ ┌───────────────────────────┐ │
│ │ Check Location │ │
│ │ Preferences │ │
│ └─────────────┬─────────────┘ │
│ │ │
│ ┌───────────┴───────────┐ │
│ ▼ ▼ │
│ ┌─────────┐ ┌───────────┐ │
│ │ Saved │ │ No Saved │ │
│ │Location │ │ Location │ │
│ └────┬────┘ └─────┬─────┘ │
│ │ │ │
│ │ ▼ │
│ │ ┌───────────────────┐ │
│ │ │ Request Browser │ │
│ │ │ Geolocation │ │
│ │ └─────────┬─────────┘ │
│ │ │ │
│ │ ┌─────────────┴─────────────┐ │
│ │ ▼ ▼ │
│ │ ┌─────────┐ ┌─────────────┐ │
│ │ │ Granted │ │ Denied │ │
│ │ └────┬────┘ └──────┬──────┘ │
│ │ │ │ │
│ │ │ ▼ │
│ │ │ ┌───────────────┐ │
│ │ │ │ Show Manual │ │
│ │ │ │ Location Entry│ │
│ │ │ └───────┬───────┘ │
│ │ │ │ │
│ └────────┼──────────────────────────┘ │
│ ▼ │
│ ┌──────────────┐ │
│ │ Load Parks │ │
│ │ Within Radius│ │
│ └──────┬───────┘ │
│ │ │
│ ┌───────┴───────┐ │
│ ▼ ▼ │
│ ┌──────────┐ ┌──────────┐ │
│ │ Map │ │ List │ │
│ │ View │ │ View │ │
│ └──────────┘ └──────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
```
---
## Authentication Flows
### Sign Up Flow
```
┌─────────────────────────────────────────────────────────────────┐
│ │
│ User Clicks "Sign Up" │
│ │ │
│ ▼ │
│ ┌───────────────────────────┐ │
│ │ Choose Sign Up Method │ │
│ └─────────────┬─────────────┘ │
│ │ │
│ ┌───────────┼───────────────────────┐ │
│ ▼ ▼ ▼ │
│ ┌─────────┐ ┌─────────┐ ┌───────────┐ │
│ │ Email │ │ Magic │ │ OAuth │ │
│ │Password │ │ Link │ │(Google/ │ │
│ └────┬────┘ └────┬────┘ │ Discord) │ │
│ │ │ └─────┬─────┘ │
│ ▼ ▼ │ │
│ ┌─────────┐ ┌─────────┐ │ │
│ │ Fill │ │ Enter │ │ │
│ │ Form │ │ Email │ │ │
│ └────┬────┘ └────┬────┘ │ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌─────────────────────────────────────────────┐ │
│ │ CAPTCHA Verification │ │
│ └─────────────────────┬───────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────┐ │
│ │ Email Sent │ │
│ │ (Confirmation) │ │
│ └────────┬─────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────┐ │
│ │ User Clicks Link │ │
│ └────────┬─────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────┐ │
│ │ Email Verified │ │
│ │ Account Active │ │
│ └────────┬─────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────┐ │
│ │ Redirect to │ │
│ │ Profile Setup │ │
│ └──────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
```
### Sign In Flow
```
┌─────────────────────────────────────────────────────────────────┐
│ │
│ User Clicks "Sign In" │
│ │ │
│ ▼ │
│ ┌───────────────────────────┐ │
│ │ Enter Credentials │ │
│ │ (Email/Password or OAuth)│ │
│ └─────────────┬─────────────┘ │
│ │ │
│ ▼ │
│ ┌───────────────────────────┐ │
│ │ Validate Credentials │ │
│ └─────────────┬─────────────┘ │
│ │ │
│ ┌───────────┴───────────┐ │
│ ▼ ▼ │
│ ┌─────────┐ ┌───────────┐ │
│ │ Success │ │ Failed │ │
│ └────┬────┘ └─────┬─────┘ │
│ │ │ │
│ │ ▼ │
│ │ ┌───────────────┐ │
│ │ │ Show Error │ │
│ │ │ (Too many │ │
│ │ │ attempts = │ │
│ │ │ lockout) │ │
│ │ └───────────────┘ │
│ │ │
│ ▼ │
│ ┌───────────────────────────┐ │
│ │ Check for MFA │ │
│ └─────────────┬─────────────┘ │
│ │ │
│ ┌─────────┴─────────┐ │
│ ▼ ▼ │
│ ┌─────────┐ ┌───────────┐ │
│ │ MFA │ │ No MFA │ │
│ │ Enabled │ │ │ │
│ └────┬────┘ └─────┬─────┘ │
│ │ │ │
│ ▼ │ │
│ ┌─────────────┐ │ │
│ │ Enter TOTP │ │ │
│ │ Code │ │ │
│ └──────┬──────┘ │ │
│ │ │ │
│ └───────────────┤ │
│ ▼ │
│ ┌──────────────────┐ │
│ │ Check Ban Status │ │
│ └────────┬─────────┘ │
│ │ │
│ ┌────────────┴────────────┐ │
│ ▼ ▼ │
│ ┌─────────┐ ┌───────────┐ │
│ │ Banned │ │ Not Banned│ │
│ └────┬────┘ └─────┬─────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌─────────┐ ┌───────────────┐ │
│ │ Show │ │ Create Session│ │
│ │ Message │ │ Redirect Home │ │
│ └─────────┘ └───────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
```
---
## Content Viewing Flows
### Park Page Journey
```
┌─────────────────────────────────────────────────────────────────┐
│ │
│ User Arrives at Park Page │
│ │ │
│ ▼ │
│ ┌───────────────────────────┐ │
│ │ View Hero Section │ │
│ │ (Banner, Name, Stats) │ │
│ └─────────────┬─────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ SELECT TAB │ │
│ ├──────────┬──────────┬──────────┬──────────┬────────────┤ │
│ │ Overview │ Rides │ Reviews │ Photos │ History │ │
│ └────┬─────┴────┬─────┴────┬─────┴────┬─────┴─────┬──────┘ │
│ │ │ │ │ │ │
│ ▼ ▼ ▼ ▼ ▼ │
│ ┌─────────┐┌─────────┐┌─────────┐┌─────────┐┌──────────┐ │
│ │ Park ││ Ride ││ Review ││ Photo ││ Version │ │
│ │ Info, ││ List ││ List ││ Gallery ││ History │ │
│ │ Map, ││ with ││ with ││ ││ Timeline │ │
│ │ Contact ││ Filters ││ Ratings ││ ││ │ │
│ └────┬────┘└────┬────┘└────┬────┘└────┬────┘└──────────┘ │
│ │ │ │ │ │
│ │ ▼ ▼ ▼ │
│ │ ┌──────────────────────────────────┐ │
│ │ │ CLICK ITEM FOR DETAIL │ │
│ │ └─────────────────┬────────────────┘ │
│ │ │ │
│ │ ┌───────────┴───────────┐ │
│ │ ▼ ▼ │
│ │ ┌───────────┐ ┌───────────┐ │
│ │ │ Ride Page │ │ Lightbox │ │
│ │ └───────────┘ └───────────┘ │
│ │ │
│ └──────────────────────────────────────────────────────▶│
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ USER ACTIONS │ │
│ ├──────────────┬──────────────┬──────────────────────────┤ │
│ │ Edit Park │ Write Review │ Upload Photo │ Log Credit│ │
│ │ (if logged) │ (if logged) │ (if logged) │(if logged)│ │
│ └──────────────┴──────────────┴──────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
```
---
## Contribution Flows
### Submit New Content Flow (The Sacred Pipeline)
```
┌─────────────────────────────────────────────────────────────────┐
│ │
│ THE SACRED PIPELINE │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ USER │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ ┌───────────────────────────────────────┐ │ │
│ │ │ SUBMISSION FORM │ │ │
│ │ │ • Multi-step wizard │ │ │
│ │ │ • Auto-save drafts │ │ │
│ │ │ • Validation │ │ │
│ │ │ • Unit conversion │ │ │
│ │ └─────────────────┬─────────────────────┘ │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ ┌───────────────────────────────────────┐ │ │
│ │ │ SUBMISSION RECORD CREATED │ │ │
│ │ │ (content_submissions table) │ │ │
│ │ │ Status: "pending" │ │ │
│ │ └─────────────────┬─────────────────────┘ │ │
│ │ │ │ │
│ └─────────────────────┼───────────────────────────────────┘ │
│ │ │
│ ┌─────────────────────┼───────────────────────────────────┐ │
│ │ ▼ │ │
│ │ MODERATION ┌───────────────────────────────────────┐ │ │
│ │ │ MODERATION QUEUE │ │ │
│ │ │ • Appears in moderator dashboard │ │ │
│ │ │ • Sorted by priority/age │ │ │
│ │ └─────────────────┬─────────────────────┘ │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ ┌───────────────────────────────────────┐ │ │
│ │ │ MODERATOR CLAIMS ITEM │ │ │
│ │ │ • Locks item for 30 minutes │ │ │
│ │ │ • Prevents conflicts │ │ │
│ │ └─────────────────┬─────────────────────┘ │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ ┌───────────────────────────────────────┐ │ │
│ │ │ REVIEW INTERFACE │ │ │
│ │ │ • Side-by-side diff │ │ │
│ │ │ • Field-by-field review │ │ │
│ │ │ • Preview changes │ │ │
│ │ └─────────────────┬─────────────────────┘ │ │
│ │ │ │ │
│ │ ┌───────────────┼───────────────┐ │ │
│ │ ▼ ▼ ▼ │ │
│ │ ┌─────────┐ ┌───────────┐ ┌─────────┐ │ │
│ │ │ APPROVE │ │ REQUEST │ │ REJECT │ │ │
│ │ │ │ │ CHANGES │ │ │ │ │
│ │ └────┬────┘ └─────┬─────┘ └────┬────┘ │ │
│ │ │ │ │ │ │
│ └────────────────┼───────────────┼──────────────┼─────────┘ │
│ │ │ │ │
│ │ ▼ ▼ │
│ │ ┌──────────────┐ ┌──────────────┐ │
│ │ │ Notify User │ │ Notify User │ │
│ │ │ To Revise │ │ With Reason │ │
│ │ └──────────────┘ └──────────────┘ │
│ │ │
│ ┌────────────────┼────────────────────────────────────────┐ │
│ │ ▼ │ │
│ │ APPROVAL ┌───────────────────────────────────────┐ │ │
│ │ │ EDGE FUNCTION TRIGGERED │ │ │
│ │ │ • Transaction begins │ │ │
│ │ │ • Create/update entity │ │ │
│ │ │ • Create version record │ │ │
│ │ │ • Update submission status │ │ │
│ │ │ • Transaction commits │ │ │
│ │ └─────────────────┬─────────────────────┘ │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ ┌───────────────────────────────────────┐ │ │
│ │ │ VERSION RECORD CREATED │ │ │
│ │ │ • Full snapshot of data │ │ │
│ │ │ • Version number incremented │ │ │
│ │ │ • is_current = true │ │ │
│ │ └─────────────────┬─────────────────────┘ │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ ┌───────────────────────────────────────┐ │ │
│ │ │ ENTITY NOW VISIBLE │ │ │
│ │ │ • Appears in listings │ │ │
│ │ │ • Searchable │ │ │
│ │ │ • Has public URL │ │ │
│ │ └───────────────────────────────────────┘ │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
```
### Edit Content Flow
```
┌─────────────────────────────────────────────────────────────────┐
│ │
│ User Clicks "Edit" on Entity │
│ │ │
│ ▼ │
│ ┌───────────────────────────┐ │
│ │ Check User Role │ │
│ └─────────────┬─────────────┘ │
│ │ │
│ ┌───────────┴───────────────────┐ │
│ ▼ ▼ │
│ ┌─────────────┐ ┌───────────────┐ │
│ │ Moderator+ │ │ Regular User │ │
│ │ (Skip mod) │ │ (Needs review)│ │
│ └──────┬──────┘ └───────┬───────┘ │
│ │ │ │
│ └───────────────────────────┤ │
│ ▼ │
│ ┌───────────────────────┐ │
│ │ Load Edit Form │ │
│ │ (Pre-filled with │ │
│ │ current data) │ │
│ └───────────┬───────────┘ │
│ │ │
│ ▼ │
│ ┌───────────────────────┐ │
│ │ User Makes Changes │ │
│ │ (Auto-save enabled) │ │
│ └───────────┬───────────┘ │
│ │ │
│ ▼ │
│ ┌───────────────────────┐ │
│ │ Add Submission Note │ │
│ │ (Explain changes) │ │
│ └───────────┬───────────┘ │
│ │ │
│ ▼ │
│ ┌───────────────────────┐ │
│ │ Submit for Review │ │
│ │ or Direct Apply │ │
│ └───────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
```
### Photo Upload Flow
```
┌─────────────────────────────────────────────────────────────────┐
│ │
│ User Clicks "Upload Photo" │
│ │ │
│ ▼ │
│ ┌───────────────────────────┐ │
│ │ Open Upload Dialog │ │
│ └─────────────┬─────────────┘ │
│ │ │
│ ▼ │
│ ┌───────────────────────────┐ │
│ │ Drag & Drop or Browse │ │
│ │ (Max 10 photos) │ │
│ └─────────────┬─────────────┘ │
│ │ │
│ ▼ │
│ ┌───────────────────────────┐ │
│ │ For Each Photo: │ │
│ │ • Validate format │ │
│ │ • Check file size │ │
│ │ • Show preview │ │
│ │ • Optional: Edit/Crop │ │
│ └─────────────┬─────────────┘ │
│ │ │
│ ▼ │
│ ┌───────────────────────────┐ │
│ │ Click "Upload" │ │
│ └─────────────┬─────────────┘ │
│ │ │
│ ▼ │
│ ┌───────────────────────────┐ │
│ │ Get CloudFlare Direct │ │
│ │ Upload URL │ │
│ └─────────────┬─────────────┘ │
│ │ │
│ ▼ │
│ ┌───────────────────────────┐ │
│ │ Upload to CloudFlare │ │
│ │ (Show progress) │ │
│ └─────────────┬─────────────┘ │
│ │ │
│ ▼ │
│ ┌───────────────────────────┐ │
│ │ Create Photo Submission │ │
│ │ (Status: Pending) │ │
│ └─────────────┬─────────────┘ │
│ │ │
│ ▼ │
│ ┌───────────────────────────┐ │
│ │ → Moderation Queue │ │
│ │ Photo visible after │ │
│ │ approval │ │
│ └───────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
```
---
## Engagement Flows
### Write Review Flow
```
┌─────────────────────────────────────────────────────────────────┐
│ │
│ User on Park/Ride Page │
│ │ │
│ ▼ │
│ ┌───────────────────────────┐ │
│ │ Click "Write Review" │ │
│ └─────────────┬─────────────┘ │
│ │ │
│ ┌───────────┴───────────┐ │
│ ▼ ▼ │
│ ┌─────────────┐ ┌───────────────┐ │
│ │ Logged In │ │ Not Logged In │ │
│ └──────┬──────┘ └───────┬───────┘ │
│ │ │ │
│ │ ▼ │
│ │ ┌───────────────────┐ │
│ │ │ Redirect to Login │ │
│ │ │ (Return after) │ │
│ │ └───────────────────┘ │
│ │ │
│ ▼ │
│ ┌───────────────────────────┐ │
│ │ Check Existing Review │ │
│ └─────────────┬─────────────┘ │
│ │ │
│ ┌───────────┴───────────┐ │
│ ▼ ▼ │
│ ┌─────────────┐ ┌───────────────┐ │
│ │ Has Review │ │ No Review │ │
│ │ (Edit mode) │ │ (Create mode) │ │
│ └──────┬──────┘ └───────┬───────┘ │
│ │ │ │
│ └──────────┬──────────┘ │
│ ▼ │
│ ┌───────────────────────────┐ │
│ │ Review Form │ │
│ │ • Star rating (1-5) │ │
│ │ • Written review text │ │
│ │ • "Experienced recently" │ │
│ └─────────────┬─────────────┘ │
│ │ │
│ ▼ │
│ ┌───────────────────────────┐ │
│ │ Submit Review │ │
│ └─────────────┬─────────────┘ │
│ │ │
│ ▼ │
│ ┌───────────────────────────┐ │
│ │ Review Visible │ │
│ │ (No moderation for │ │
│ │ reviews by default) │ │
│ └───────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
```
### Log Ride Credit Flow
```
┌─────────────────────────────────────────────────────────────────┐
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ ENTRY POINTS │ │
│ ├───────────────┬───────────────┬─────────────────────────┤ │
│ │ Ride Page │ Credits Page │ Quick Add │ │
│ │ "Log Credit" │ "Add Credit" │ (Previously │ │
│ │ Button │ Button │ logged rides) │ │
│ └───────┬───────┴───────┬───────┴───────────┬─────────────┘ │
│ │ │ │ │
│ ▼ ▼ │ │
│ ┌──────────────────────────────────┐ │ │
│ │ RIDE SEARCH (if not from ride) │ │ │
│ │ • Search by name │ │ │
│ │ • Filter by park │ │ │
│ │ • Recent rides shown │ │ │
│ └──────────────┬───────────────────┘ │ │
│ │ │ │
│ └───────────────────────────┤ │
│ ▼ │
│ ┌────────────────────────────────┐ │
│ │ CREDIT DETAILS │ │
│ │ • Ride count (default: 1) │ │
│ │ • Date (optional) │ │
│ │ • Notes (optional) │ │
│ └────────────────┬───────────────┘ │
│ │ │
│ ▼ │
│ ┌────────────────────────────────┐ │
│ │ SAVE CREDIT │ │
│ └────────────────┬───────────────┘ │
│ │ │
│ ┌───────────────┴───────────────┐ │
│ ▼ ▼ │
│ ┌──────────────┐ ┌──────────────┐
│ │ New Credit │ │ Update │
│ │ Created │ │ Existing │
│ └──────────────┘ └──────────────┘
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ QUICK INCREMENT │ │
│ │ On credit card: [] 12 [+] │ │
│ │ Instantly updates count without dialog │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
```
---
## Moderation Flows
### Moderator Queue Workflow
```
┌─────────────────────────────────────────────────────────────────┐
│ │
│ Moderator Opens Queue │
│ │ │
│ ▼ │
│ ┌───────────────────────────┐ │
│ │ View Pending Items │ │
│ │ (Sorted by priority/age) │ │
│ └─────────────┬─────────────┘ │
│ │ │
│ ▼ │
│ ┌───────────────────────────┐ │
│ │ Apply Filters │ │
│ │ • Type (park/ride/etc) │ │
│ │ • Status │ │
│ │ • Priority │ │
│ │ • Assigned to me │ │
│ └─────────────┬─────────────┘ │
│ │ │
│ ▼ │
│ ┌───────────────────────────┐ │
│ │ Select Item to Review │ │
│ └─────────────┬─────────────┘ │
│ │ │
│ ▼ │
│ ┌───────────────────────────┐ │
│ │ CLAIM ITEM │ │
│ │ • Locks for 30 mins │ │
│ │ • Others can't claim │ │
│ │ • Can unclaim if needed │ │
│ └─────────────┬─────────────┘ │
│ │ │
│ ▼ │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ REVIEW INTERFACE │ │
│ ├───────────────────────┬───────────────────────────────┤ │
│ │ CURRENT DATA │ PROPOSED CHANGES │ │
│ │ │ │ │
│ │ Name: Cedar Point │ Name: Cedar Point │ │
│ │ Status: Open │ Status: Open │ │
│ │ Rides: 72 │ Rides: 73 ← CHANGED │ │
│ │ Desc: ... │ Desc: ... (expanded) ← + │ │
│ │ │ │ │
│ └───────────────────────┴───────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌───────────────────────────┐ │
│ │ Add Reviewer Notes │ │
│ │ (Internal notes for │ │
│ │ decision reasoning) │ │
│ └─────────────┬─────────────┘ │
│ │ │
│ ┌───────────┼───────────────────┐ │
│ ▼ ▼ ▼ │
│ ┌─────────┐ ┌─────────┐ ┌───────────────┐ │
│ │ APPROVE │ │ REJECT │ │ REQUEST │ │
│ │ ALL │ │ │ │ CHANGES │ │
│ └────┬────┘ └────┬────┘ └───────┬───────┘ │
│ │ │ │ │
│ │ ▼ ▼ │
│ │ ┌───────────────┐ ┌───────────────┐ │
│ │ │ Notify User │ │ Notify User │ │
│ │ │ (Rejected) │ │ (Changes │ │
│ │ │ │ │ Needed) │ │
│ │ └───────────────┘ └───────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────┐ │
│ │ APPROVAL PROCESSING │ │
│ │ • Edge function called │ │
│ │ • Transaction executed │ │
│ │ • Entity updated │ │
│ │ • Version created │ │
│ │ • User notified │ │
│ └──────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
```
---
## Admin Flows
### User Management Flow
```
┌─────────────────────────────────────────────────────────────────┐
│ │
│ Admin Opens User Management │
│ │ │
│ ▼ │
│ ┌───────────────────────────┐ │
│ │ Search/Filter Users │ │
│ │ • By username/email │ │
│ │ • By role │ │
│ │ • By status │ │
│ └─────────────┬─────────────┘ │
│ │ │
│ ▼ │
│ ┌───────────────────────────┐ │
│ │ Select User │ │
│ └─────────────┬─────────────┘ │
│ │ │
│ ▼ │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ USER PROFILE VIEW │ │
│ │ │ │
│ │ Username: coasterFan123 │ │
│ │ Email: user@email.com │ │
│ │ Role: User │ │
│ │ Status: Active │ │
│ │ Joined: Jan 2024 │ │
│ │ │ │
│ │ Stats: 45 submissions, 12 reviews, 89 credits │ │
│ │ │ │
│ └───────────────────────────────────────────────────────┘ │
│ │ │
│ ┌───────────┼───────────────────┬───────────────────┐ │
│ ▼ ▼ ▼ ▼ │
│ ┌─────────┐ ┌─────────┐ ┌───────────┐ ┌───────────┐ │
│ │ Change │ │ Ban │ │ Unban │ │ Delete │ │
│ │ Role │ │ User │ │ User │ │ User │ │
│ └────┬────┘ └────┬────┘ └─────┬─────┘ └─────┬─────┘ │
│ │ │ │ │ │
│ ▼ ▼ ▼ ▼ │
│ ┌─────────┐ ┌───────────────┐ ┌───────────┐ ┌───────────────┐ │
│ │ Select │ │ Enter Reason │ │ Confirm │ │ Confirm │ │
│ │ New Role│ │ Set Duration │ │ Unban │ │ (Irreversible)│ │
│ └────┬────┘ └───────┬───────┘ └─────┬─────┘ └───────┬───────┘ │
│ │ │ │ │ │
│ └──────────────┼───────────────┼────────────────┘ │
│ ▼ │
│ ┌───────────────────┐ │
│ │ Action Logged │ │
│ │ (Audit Trail) │ │
│ └───────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
```
---
## Notification Flow
```
┌─────────────────────────────────────────────────────────────────┐
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ EVENT TRIGGERS │ │
│ ├──────────────┬──────────────┬──────────────┬────────────┤ │
│ │ Submission │ Review │ Social │ System │ │
│ │ Status │ Response │ Interaction │ Alert │ │
│ │ Changed │ │ │ │ │
│ └──────┬───────┴──────┬───────┴──────┬───────┴──────┬─────┘ │
│ │ │ │ │ │
│ └──────────────┴──────────────┴──────────────┘ │
│ │ │
│ ▼ │
│ ┌───────────────────────────┐ │
│ │ CHECK USER PREFERENCES │ │
│ │ • Notification enabled? │ │
│ │ • Email enabled? │ │
│ │ • In-app enabled? │ │
│ └─────────────┬─────────────┘ │
│ │ │
│ ┌───────────────────┼───────────────────┐ │
│ ▼ ▼ ▼ │
│ ┌───────────┐ ┌───────────────┐ ┌───────────┐ │
│ │ In-App │ │ Email │ │ None │ │
│ │ Only │ │ + In-App │ │ (Opted │ │
│ │ │ │ │ │ Out) │ │
│ └─────┬─────┘ └───────┬───────┘ └───────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌───────────┐ ┌───────────────┐ │
│ │ NOVU │ │ NOVU │ │
│ │ In-App │ │ Email + App │ │
│ │ Channel │ │ Channels │ │
│ └─────┬─────┘ └───────┬───────┘ │
│ │ │ │
│ └────────────────────┤ │
│ ▼ │
│ ┌───────────────────────────┐ │
│ │ NOTIFICATION DELIVERED │ │
│ └─────────────┬─────────────┘ │
│ │ │
│ ┌───────────────────┴───────────────────┐ │
│ ▼ ▼ │
│ ┌───────────────┐ ┌───────────────┐ │
│ │ NOTIFICATION │ │ EMAIL │ │
│ │ BELL │ │ INBOX │ │
│ │ (🔔 Badge) │ │ │ │
│ └───────┬───────┘ └───────────────┘ │
│ │ │
│ ▼ │
│ ┌───────────────────────────┐ │
│ │ NOTIFICATION FEED │ │
│ │ • Mark as read │ │
│ │ • Click to navigate │ │
│ │ • Mark all as read │ │
│ └───────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
```
---
## Related Documentation
- [Site Overview](./SITE_OVERVIEW.md)
- [Design System](./DESIGN_SYSTEM.md)
- [Pages](./PAGES.md)
- [Components](./COMPONENTS.md)