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` ### 1. `apps.core`
- **Responsibility**: Base classes, shared utilities, history tracking. - **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` ### 2. `apps.accounts`
- **Responsibility**: User authentication and profiles. - **Responsibility**: User authentication, profiles, and settings.
- **Existing**: `User`, `UserProfile`, `UserDeletionRequest`, `UserNotification`. - **Existing**: `User`, `UserProfile` (bio, location, home park).
- **Missing**: `RideCredit` (although `UserProfile` has aggregate stats, individual credits are needed). - **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` ### 3. `apps.parks`
- **Responsibility**: Park management. - **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` ### 4. `apps.rides`
- **Responsibility**: Ride data (rides, coasters, credits), Companies. - **Responsibility**: Ride data, Coasters, and Credits.
- **Existing**: `RideModel`, `RideModelVariant`, `RideModelPhoto`, `Ride` (with FSM `status`). - **Models**:
- **Proposed Additions**: - `Ride`: Core entity (Status FSM: Operating, SBNO, Closed, etc.).
- `RideCredit` (M2M: User <-> Ride). **Attributes**: `count`, `first_ridden_at`, `notes`, `rating`. **Constraint**: Unique(user, ride). - `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` ### 5. `apps.companies`
- **Responsibility**: Content pipeline. - **Responsibility**: Management of Industry Entities (Section 4).
- **Existing**: Check `apps/moderation` (likely `Submission`). - **Models**:
- **Constraint**: Status State Machine (Pending -> Claimed -> Approved). - `Company`: Single model with `type` choices or Polymorphic.
- **Types**: `Manufacturer`, `Designer`, `Operator`, `PropertyOwner`.
- **Features**: Detailed pages, hover cards, listing by type.
### 6. `apps.media` ### 6. `apps.moderation` (The Sacred Submission Pipeline)
- **Responsibility**: Photos and Videos. - **Responsibility**: Centralized Content Submission System (Section 14, 16).
- **Existing**: `Photo` (GenericFK). - **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` ### 7. `apps.media`
- **Responsibility**: User reviews. - **Responsibility**: Media Management (Section 13).
- **Proposed**: `Review` model (User, Entity GenericFK, rating 1-5, text, helpful votes). - **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` ### 8. `apps.reviews`
- **Responsibility**: User Rankings/Lists. - **Responsibility**: Public Reviews & Ratings (Section 12).
- **Existing**: `TopList`, `TopListItem` (in `apps.accounts` currently? Or should move to `apps.lists`?). `accounts/models.py` has `TopList`. - **Models**:
- **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. - `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` ### 9. `apps.lists`
- **Responsibility**: Blog posts. - **Responsibility**: User Lists & Rankings (Section 11).
- **Proposed**: `Post`, `Tag`. - **Models**:
- `UserList`: Title, Description, Type (Park/Ride/Coaster/Mixed), Privacy (Public/Private).
- `UserListItem`: FK to List, GenericFK to Item, Order, Notes.
### 10. `apps.support` ### 10. `apps.blog`
- **Responsibility**: Contact form. - **Responsibility**: News & Updates.
- **Proposed**: `Ticket`. - **Models**: `Post`, `Tag`.
### 11. `apps.support`
- **Responsibility**: Human interaction.
- **Models**: `Ticket` (Contact Form).

View File

@@ -2,67 +2,112 @@
## User Review Required ## User Review Required
> [!IMPORTANT] > [!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. > **Measurement Unit System**: The backend will store all values in **Metric**. The Frontend (`useUnits` composable) will handle conversion to Imperial based on user preference.
> **Moderation Workflow**: A State Machine (Pending -> Claimed -> Approved) will be enforced for all user submissions. > **Sacred Pipeline Enforcement**: All user edits create `Submission` records (stored as JSON). No direct database edits are allowed for non-admin users.
## Proposed Changes ## Proposed Changes
### Backend (Django + DRF) ### Backend (Django + DRF)
#### App Structure & Models #### 1. Core & Auth Infrastructure
- [x] **`apps/core`**: Base models (`SluggedModel`, `TimeStampedModel`), History (`pghistory`, `SlugHistory`), Utilities. - [x] **`apps.core`**: Implement `TrackedModel` using `pghistory` for all core entities to support Edit History and Versioning (Section 15).
- [x] **`apps/accounts`**: `User`, `UserProfile` (bio, location, visual prefs), `Auth` (MFA, Magic Link). - [x] **`apps.accounts`**:
- [x] **`apps/parks`**: `Park` (name, location, dates, status, owner/operator FKs). - `User` & `UserProfile` models (Bio, Location, Home Park).
- [x] **`apps/rides`**: - **Settings Support**: Endpoints for changing Email, Password, MFA, and Sessions (Section 9.1-9.2).
- `Ride` (name, park FK, model FK, specs: height/speed/etc stored in metric). - **Privacy**: Fields for `public_profile`, `show_location`, etc. (Section 9.3).
- `Manufacturer`, `Designer`, `RideModel` (Company models). - **Data Export**: Endpoint to generate JSON dump of all user data (Section 9.6).
- `RideCredit` (M2M: User <-> Ride). **Attributes**: `count`, `first_ridden_at`, `notes`, `ranking`. - **Account Deletion**: `UserDeletionRequest` model with 7-day grace period (Section 9.6).
- [ ] **`apps/reviews`**: `Review` (User, Entity GenericFK, rating 1-5, text, helpful votes).
- [ ] **`apps/media`**: `Photo` (image, user, caption, entity GenericFK), `Video`.
- [x] **`apps/lists`**: `UserList` (Custom rankings/lists).
- [x] **`apps/moderation`**: `Submission` (User, ContentType, Object ID, Changes JSON, Status: Pending/Claimed/Approved/Rejected, Moderator FK), `Report`.
- [ ] **`apps/blog`**: `Post`, `Tag`.
- [ ] **`apps/support`**: `Ticket` (Contact form).
#### API & Logic #### 2. Entity Models & Logic ("Live" Data)
- **DRF ViewSets**: Full CRUD for all entities (read-only for public, authenticated for mutations). - [x] **`apps.parks`**: `Park` (with Operator/Owner FKs, Geolocation).
- **Moderation Middleware/Signals**: Intercept mutations to create `Submission` records instead of direct saves for non-staff. - [x] **`apps.rides`**: `Ride` (Status FSM), `RideModel`, `Manufacturer`, `Designer`.
- **Versioning**: `pghistory` and `SlugHistory` are already partially implemented in `core`. - [x] **`apps.rides` (Credits)**: `RideCredit` Through-Model with `count`, `rating`, `date`, `notes`. Constraint: Unique(user, ride).
- **Search**: Global search endpoint. - [x] **`apps.companies`**: `Company` model with types (`Manufacturer`, `Designer`, `Operator`, `Owner`).
- **Geolocation**: `PostGIS` integrations (already partially in `parks.location_utils`). - [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) ### Frontend (Nuxt 4)
#### Architecture #### 1. Initial Setup & Core
- **Directory Structure**: - [x] **Composables**: `useUnits` (Metric/Imperial), `useAuth` (MFA, Session), `useApi`.
- `app/pages/`: File-based routing (e.g., `parks/[slug].vue`). - [x] **Layouts**: Standard Layout (Hero, Tabs), Auth Layout.
- `app/components/`: Reusable UI components (Design System).
- `app/composables/`: Logic reuse (`useUnits`, `useAuth`, `useApi`).
- `app/stores/`: Pinia stores (`userStore`, `toastStore`).
- `app/layouts/`: `default.vue`, `auth.vue`.
- **Tech Stack**: Nuxt 4, Nuxt UI (Tailwind based), Pinia, VueUse.
#### Key Features & Composables #### 2. Discovery & Search (Section 1 & 6)
- **`useUnits`**: Reactively converts metric props to imperial if user pref is set. - [x] **Global Search**: Hero Search with Autocomplete (Parks, Rides, Companies).
- **`useAuth`**: Handles JWT/Session, MFA state, User fetching. - [x] **Discovery Tabs** (11 Sections):
- **`useModeration`**: For moderators to claim/approve actions. - [x] Trending Parks / Rides
- **Forms**: `Zod` schema validation matching DRF serializers. - [x] New Parks / Rides
- [x] Top Parks / Rides
- [x] Opening Soon / Recently Opened
- [x] Closing Soon / Recently Closed
- [x] Recent Changes Feed
#### Design System #### 3. Content Pages (Read-Only Views)
- **Theme**: Dark/Light mode support (built-in to Nuxt UI). - [ ] **Park Detail**: Tabs (Overview, Rides, Reviews, Photos, History).
- **Components**: - [ ] **Ride Detail**: Tabs (Overview, Specifications, Reviews, Photos, History).
- `EntityCard` (Park/Ride summary). - [ ] **Company Pages**: Manufacturer, Designer, Operator, Property Owner details.
- `StandardLayout` (Hero, Tabs, Content). - [ ] **Maps**: Interactive "Parks Nearby" map.
- `MediaGallery`.
- `ReviewList`. #### 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 ## Verification Plan
### Automated Tests ### Automated Tests
- **Backend**: `pytest` for Model constraints, API endpoints, and Moderation flow. - **Backend**: `pytest` for all Model constraints and API permissions.
- **Frontend**: `vitest` for Unit/Composable tests. E2E tests for critical flows (Submission -> Moderation -> Publish). - Test Submission State Machine: `Pending -> Claimed -> Approved`.
- Test Versioning: Ensure `pghistory` tracks changes on approval.
- **Frontend**: `vitest` for Unit Tests (Composables).
### Manual Verification ### Manual Verification Flows
1. **Ride Credits**: User adds a ride, verifies count increments. 1. **Sacred Pipeline Flow**:
2. **Moderation**: User submits data -> Mod claims -> Mod approves -> Public data updates. - **User**: Submit a change to "Top Thrill 2" (add stats).
3. **Units**: Toggle preference, verify stats update (e.g., km/h -> mph). - **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 = [ dependencies = [
("accounts", "0013_add_user_query_indexes"), ("accounts", "0013_add_user_query_indexes"),
("contenttypes", "0002_remove_content_type_name"), ("contenttypes", "0002_remove_content_type_name"),
( ("django_cloudflareimages_toolkit", "0001_initial"),
"django_cloudflareimages_toolkit",
"0002_rename_cloudflare_i_user_id_b8c8a5_idx_cloudflare__user_id_a3ad50_idx_and_more",
),
] ]
operations = [ 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( migrations.AlterModelOptions(
name="user", name="user",
options={"verbose_name": "User", "verbose_name_plural": "Users"}, options={"verbose_name": "User", "verbose_name_plural": "Users"},

View File

@@ -15,15 +15,12 @@ from apps.accounts.admin import (
CustomUserAdmin, CustomUserAdmin,
EmailVerificationAdmin, EmailVerificationAdmin,
PasswordResetAdmin, PasswordResetAdmin,
TopListAdmin,
TopListItemAdmin,
UserProfileAdmin, UserProfileAdmin,
) )
from apps.accounts.models import ( from apps.accounts.models import (
EmailVerification, EmailVerification,
PasswordReset, PasswordReset,
TopList,
TopListItem,
User, User,
UserProfile, UserProfile,
) )
@@ -157,51 +154,4 @@ class TestPasswordResetAdmin(TestCase):
assert "cleanup_old_tokens" in actions 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 # Import all models
from apps.accounts.models import ( from apps.accounts.models import (
User, UserProfile, TopList, TopListItem, UserNotification, User, UserProfile, UserNotification,
NotificationPreference, UserDeletionRequest NotificationPreference, UserDeletionRequest
) )
from apps.parks.models import ( from apps.parks.models import (
@@ -128,7 +128,7 @@ class Command(BaseCommand):
# Create content and interactions # Create content and interactions
self.create_reviews(options['reviews'], users, parks, rides) self.create_reviews(options['reviews'], users, parks, rides)
self.create_top_lists(users, parks, rides)
self.create_notifications(users) self.create_notifications(users)
self.create_moderation_data(users, parks, rides) self.create_moderation_data(users, parks, rides)
@@ -149,7 +149,7 @@ class Command(BaseCommand):
models_to_clear = [ models_to_clear = [
# Content and interactions (clear first) # Content and interactions (clear first)
TopListItem, TopList, UserNotification, NotificationPreference, UserNotification, NotificationPreference,
ParkReview, RideReview, ModerationAction, ModerationQueue, ParkReview, RideReview, ModerationAction, ModerationQueue,
# Media # Media
@@ -1042,65 +1042,7 @@ class Command(BaseCommand):
self.stdout.write(f' ✅ Created {count} reviews') 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: def create_notifications(self, users: List[User]) -> None:
"""Create sample notifications for users""" """Create sample notifications for users"""
@@ -1198,7 +1140,7 @@ class Command(BaseCommand):
'Ride Models': RideModel.objects.count(), 'Ride Models': RideModel.objects.count(),
'Park Reviews': ParkReview.objects.count(), 'Park Reviews': ParkReview.objects.count(),
'Ride Reviews': RideReview.objects.count(), 'Ride Reviews': RideReview.objects.count(),
'Top Lists': TopList.objects.count(),
'Notifications': UserNotification.objects.count(), 'Notifications': UserNotification.objects.count(),
'Park Photos': ParkPhoto.objects.count(), 'Park Photos': ParkPhoto.objects.count(),
'Ride Photos': RidePhoto.objects.count(), 'Ride Photos': RidePhoto.objects.count(),

View File

@@ -1,6 +1,6 @@
from rest_framework import serializers from rest_framework import serializers
from drf_spectacular.utils import extend_schema_field 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 from apps.accounts.serializers import UserSerializer # existing shared user serializer
@@ -11,10 +11,21 @@ class UserProfileCreateInputSerializer(serializers.ModelSerializer):
class UserProfileUpdateInputSerializer(serializers.ModelSerializer): class UserProfileUpdateInputSerializer(serializers.ModelSerializer):
cloudflare_image_id = serializers.CharField(write_only=True, required=False)
class Meta: class Meta:
model = UserProfile model = UserProfile
fields = "__all__" 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): class UserProfileOutputSerializer(serializers.ModelSerializer):
@@ -38,49 +49,3 @@ class UserProfileOutputSerializer(serializers.ModelSerializer):
if avatar: if avatar:
return getattr(avatar, "url", None) return getattr(avatar, "url", None)
return 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, views.cancel_account_deletion,
name="cancel_account_deletion", name="cancel_account_deletion",
), ),
# Data Export endpoint
path("data-export/", views.export_user_data, name="export_user_data"),
# User profile endpoints # User profile endpoints
path("profile/", views.get_user_profile, name="get_user_profile"), path("profile/", views.get_user_profile, name="get_user_profile"),
path("profile/account/", views.update_user_account, name="update_user_account"), 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/upload/", views.upload_avatar, name="upload_avatar"),
path("profile/avatar/save/", views.save_avatar_image, name="save_avatar_image"), path("profile/avatar/save/", views.save_avatar_image, name="save_avatar_image"),
path("profile/avatar/delete/", views.delete_avatar, name="delete_avatar"), 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 ( from apps.api.v1.serializers.accounts import (
CompleteUserSerializer, CompleteUserSerializer,
PublicUserSerializer,
UserPreferencesSerializer, UserPreferencesSerializer,
NotificationSettingsSerializer, NotificationSettingsSerializer,
PrivacySettingsSerializer, PrivacySettingsSerializer,
@@ -23,6 +24,7 @@ from apps.api.v1.serializers.accounts import (
AvatarUploadSerializer, AvatarUploadSerializer,
) )
from apps.accounts.services import UserDeletionService from apps.accounts.services import UserDeletionService
from apps.accounts.export_service import UserExportService
from apps.accounts.models import ( from apps.accounts.models import (
User, User,
UserProfile, UserProfile,
@@ -1583,6 +1585,57 @@ def upload_avatar(request):
return Response(serializer.errors, status=status.HTTP_400_BAD_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 === # === 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: class ModelChoices:
"""Model choices utility class.""" """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 === # === AUTHENTICATION SERIALIZERS ===
@@ -480,129 +471,4 @@ class UserProfileUpdateInputSerializer(serializers.Serializer):
water_ride_credits = serializers.IntegerField(required=False) 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 # Create routers for nested ride endpoints
ride_photos_router = DefaultRouter() ride_photos_router = DefaultRouter()
ride_photos_router.register(r"", RidePhotoViewSet, basename="ride-photo") ride_photos_router.register(r"", RidePhotoViewSet, basename="ride-photo")
from .ride_reviews_views import RideReviewViewSet
ride_reviews_router = DefaultRouter() ride_reviews_router = DefaultRouter()
ride_reviews_router.register(r"", RideReviewViewSet, basename="ride-review") 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" app_name = "api_v1_parks"
urlpatterns = [ urlpatterns = [
@@ -86,7 +95,7 @@ urlpatterns = [
name="park-image-settings", name="park-image-settings",
), ),
# Park photo endpoints - domain-specific photo management # 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 # Nested ride photo endpoints - photos for specific rides within parks
path("<str:park_slug>/rides/<str:ride_slug>/photos/", include(ride_photos_router.urls)), path("<str:park_slug>/rides/<str:ride_slug>/photos/", include(ride_photos_router.urls)),
@@ -96,6 +105,15 @@ urlpatterns = [
# Nested ride review endpoints - reviews for specific rides within parks # Nested ride review endpoints - reviews for specific rides within parks
path("<str:park_slug>/rides/<str:ride_slug>/reviews/", include(ride_reviews_router.urls)), 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 # Roadtrip API endpoints
path("roadtrip/create/", CreateTripView.as_view(), name="roadtrip-create"), path("roadtrip/create/", CreateTripView.as_view(), name="roadtrip-create"),
path("roadtrip/find-along-route/", FindParksAlongRouteView.as_view(), name="roadtrip-find"), path("roadtrip/find-along-route/", FindParksAlongRouteView.as_view(), name="roadtrip-find"),

View File

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

View File

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

View File

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

View File

@@ -18,6 +18,7 @@ from apps.accounts.models import (
NotificationPreference, NotificationPreference,
) )
from apps.lists.models import UserList from apps.lists.models import UserList
from apps.rides.models.credits import RideCredit
from apps.core.choices.serializers import RichChoiceFieldSerializer from apps.core.choices.serializers import RichChoiceFieldSerializer
UserModel = get_user_model() UserModel = get_user_model()
@@ -66,6 +67,8 @@ class UserProfileSerializer(serializers.ModelSerializer):
avatar_url = serializers.SerializerMethodField() avatar_url = serializers.SerializerMethodField()
avatar_variants = serializers.SerializerMethodField() avatar_variants = serializers.SerializerMethodField()
total_credits = serializers.SerializerMethodField()
unique_parks = serializers.SerializerMethodField()
class Meta: class Meta:
model = UserProfile model = UserProfile
@@ -87,8 +90,19 @@ class UserProfileSerializer(serializers.ModelSerializer):
"water_ride_credits", "water_ride_credits",
"unit_system", "unit_system",
"location", "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): def get_avatar_url(self, obj):
"""Get the avatar URL with fallback to default letter-based avatar.""" """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"] 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 === # === 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, NewContentAPIView,
TriggerTrendingCalculationAPIView, TriggerTrendingCalculationAPIView,
) )
from .views.discovery import DiscoveryAPIView
from .views.stats import StatsAPIView, StatsRecalculateAPIView from .views.stats import StatsAPIView, StatsRecalculateAPIView
from .views.reviews import LatestReviewsAPIView from .views.reviews import LatestReviewsAPIView
from django.urls import path, include from django.urls import path, include
@@ -44,6 +45,7 @@ urlpatterns = [
), ),
# Trending system endpoints # Trending system endpoints
path("trending/", TrendingAPIView.as_view(), name="trending"), path("trending/", TrendingAPIView.as_view(), name="trending"),
path("discovery/", DiscoveryAPIView.as_view(), name="discovery"),
path("new-content/", NewContentAPIView.as_view(), name="new-content"), path("new-content/", NewContentAPIView.as_view(), name="new-content"),
path( path(
"trending/calculate/", "trending/calculate/",
@@ -75,6 +77,11 @@ urlpatterns = [
path("maps/", include("apps.api.v1.maps.urls")), path("maps/", include("apps.api.v1.maps.urls")),
path("lists/", include("apps.lists.urls")), path("lists/", include("apps.lists.urls")),
path("moderation/", include("apps.moderation.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 # Cloudflare Images Toolkit API endpoints
path("cloudflare-images/", include("django_cloudflareimages_toolkit.urls")), path("cloudflare-images/", include("django_cloudflareimages_toolkit.urls")),
# Include router URLs (for rankings and any other router-registered endpoints) # 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.utils.decorators import method_decorator
from django.views.decorators.vary import vary_on_headers from django.views.decorators.vary import vary_on_headers
from django.views import View from django.views import View
from rest_framework.response import Response as DRFResponse
from apps.core.services.enhanced_cache_service import EnhancedCacheService from apps.core.services.enhanced_cache_service import EnhancedCacheService
import logging import logging
@@ -81,6 +82,14 @@ def cache_api_response(
"cache_hit": True, "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 return cached_response
# Execute view and cache result # Execute view and cache result
@@ -90,8 +99,18 @@ def cache_api_response(
# Only cache successful responses # Only cache successful responses
if hasattr(response, "status_code") and response.status_code == 200: 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( getattr(cache_service, cache_backend + "_cache").set(
cache_key, response, timeout cache_key, cache_payload, timeout
) )
logger.debug( logger.debug(
f"Cached API response for view {view_func.__name__}", f"Cached API response for view {view_func.__name__}",

View File

@@ -16,3 +16,13 @@ class IsOwnerOrReadOnly(permissions.BasePermission):
if hasattr(obj, 'user'): if hasattr(obj, 'user'):
return obj.user == request.user return obj.user == request.user
return False 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( parks = Park.objects.filter(
Q(name__icontains=query) Q(name__icontains=query)
| Q(slug__icontains=query.lower().replace(" ", "-")) | Q(slug__icontains=query.lower().replace(" ", "-"))
| Q(former_names__icontains=query)
)[: self.MAX_CANDIDATES] )[: self.MAX_CANDIDATES]
for park in parks: for park in parks:
@@ -249,7 +248,6 @@ class EntityFuzzyMatcher:
rides = Ride.objects.select_related("park").filter( rides = Ride.objects.select_related("park").filter(
Q(name__icontains=query) Q(name__icontains=query)
| Q(slug__icontains=query.lower().replace(" ", "-")) | Q(slug__icontains=query.lower().replace(" ", "-"))
| Q(former_names__icontains=query)
| Q(park__name__icontains=query) | Q(park__name__icontains=query)
)[: self.MAX_CANDIDATES] )[: 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 django.db.models.deletion
import apps.media.models
import apps.media.storage
import pgtrigger.compiler import pgtrigger.compiler
import pgtrigger.migrations import pgtrigger.migrations
from django.conf import settings from django.conf import settings
@@ -15,6 +13,7 @@ class Migration(migrations.Migration):
dependencies = [ dependencies = [
("contenttypes", "0002_remove_content_type_name"), ("contenttypes", "0002_remove_content_type_name"),
("django_cloudflareimages_toolkit", "0001_initial"),
("pghistory", "0006_delete_aggregateevent"), ("pghistory", "0006_delete_aggregateevent"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL), migrations.swappable_dependency(settings.AUTH_USER_MODEL),
] ]
@@ -23,88 +22,82 @@ class Migration(migrations.Migration):
migrations.CreateModel( migrations.CreateModel(
name="Photo", name="Photo",
fields=[ fields=[
( ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
"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)),
("created_at", models.DateTimeField(auto_now_add=True)), ("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)), ("updated_at", models.DateTimeField(auto_now=True)),
("date_taken", models.DateTimeField(blank=True, null=True)), ("object_id", models.PositiveIntegerField(help_text="ID of the item")),
("object_id", models.PositiveIntegerField()), ("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", "content_type",
models.ForeignKey( models.ForeignKey(
help_text="Type of item this photo belongs to",
on_delete=django.db.models.deletion.CASCADE, on_delete=django.db.models.deletion.CASCADE,
to="contenttypes.contenttype", to="contenttypes.contenttype",
), ),
), ),
( (
"uploaded_by", "image",
models.ForeignKey( models.ForeignKey(
null=True, help_text="Cloudflare Image reference",
on_delete=django.db.models.deletion.SET_NULL, on_delete=django.db.models.deletion.CASCADE,
related_name="uploaded_photos", 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, to=settings.AUTH_USER_MODEL,
), ),
), ),
], ],
options={ options={
"ordering": ["-is_primary", "-created_at"], "verbose_name": "Photo",
"verbose_name_plural": "Photos",
"ordering": ["-created_at"],
"abstract": False,
}, },
), ),
migrations.CreateModel( migrations.CreateModel(
name="PhotoEvent", name="PhotoEvent",
fields=[ 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_created_at", models.DateTimeField(auto_now_add=True)),
("pgh_label", models.TextField(help_text="The event label.")), ("pgh_label", models.TextField(help_text="The event label.")),
("id", models.BigIntegerField()), ("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)), ("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)), ("updated_at", models.DateTimeField(auto_now=True)),
("date_taken", models.DateTimeField(blank=True, null=True)), ("object_id", models.PositiveIntegerField(help_text="ID of the item")),
("object_id", models.PositiveIntegerField()), ("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", "content_type",
models.ForeignKey( models.ForeignKey(
db_constraint=False, db_constraint=False,
help_text="Type of item this photo belongs to",
on_delete=django.db.models.deletion.DO_NOTHING, on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+", related_name="+",
related_query_name="+", related_query_name="+",
to="contenttypes.contenttype", 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", "pgh_context",
models.ForeignKey( models.ForeignKey(
@@ -125,10 +118,10 @@ class Migration(migrations.Migration):
), ),
), ),
( (
"uploaded_by", "user",
models.ForeignKey( models.ForeignKey(
db_constraint=False, db_constraint=False,
null=True, help_text="User who uploaded this photo",
on_delete=django.db.models.deletion.DO_NOTHING, on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+", related_name="+",
related_query_name="+", related_query_name="+",
@@ -142,18 +135,15 @@ class Migration(migrations.Migration):
), ),
migrations.AddIndex( migrations.AddIndex(
model_name="photo", model_name="photo",
index=models.Index( index=models.Index(fields=["content_type", "object_id"], name="media_photo_content_0187f5_idx"),
fields=["content_type", "object_id"],
name="media_photo_content_0187f5_idx",
),
), ),
pgtrigger.migrations.AddTrigger( pgtrigger.migrations.AddTrigger(
model_name="photo", model_name="photo",
trigger=pgtrigger.compiler.Trigger( trigger=pgtrigger.compiler.Trigger(
name="insert_insert", name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql( 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;', 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="[AWS-SECRET-REMOVED]", hash="05c2d557f631f80ebd4b37ffb1ba9a539fa54244",
operation="INSERT", operation="INSERT",
pgid="pgtrigger_insert_insert_e1ca0", pgid="pgtrigger_insert_insert_e1ca0",
table="media_photo", table="media_photo",
@@ -167,8 +157,8 @@ class Migration(migrations.Migration):
name="update_update", name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql( sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)", 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;', 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="[AWS-SECRET-REMOVED]", hash="9a4caabe540c0fd782b9c148444c364e385327f4",
operation="UPDATE", operation="UPDATE",
pgid="pgtrigger_update_update_6ff7d", pgid="pgtrigger_update_update_6ff7d",
table="media_photo", 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, PhotoSubmission,
) )
# EditSubmission callbacks # EditSubmission callbacks (transitions from CLAIMED state)
register_callback( register_callback(
EditSubmission, 'status', 'PENDING', 'APPROVED', EditSubmission, 'status', 'CLAIMED', 'APPROVED',
SubmissionApprovedNotification() SubmissionApprovedNotification()
) )
register_callback( register_callback(
EditSubmission, 'status', 'PENDING', 'APPROVED', EditSubmission, 'status', 'CLAIMED', 'APPROVED',
ModerationCacheInvalidation() ModerationCacheInvalidation()
) )
register_callback( register_callback(
EditSubmission, 'status', 'PENDING', 'REJECTED', EditSubmission, 'status', 'CLAIMED', 'REJECTED',
SubmissionRejectedNotification() SubmissionRejectedNotification()
) )
register_callback( register_callback(
EditSubmission, 'status', 'PENDING', 'REJECTED', EditSubmission, 'status', 'CLAIMED', 'REJECTED',
ModerationCacheInvalidation() ModerationCacheInvalidation()
) )
register_callback( register_callback(
EditSubmission, 'status', 'PENDING', 'ESCALATED', EditSubmission, 'status', 'CLAIMED', 'ESCALATED',
SubmissionEscalatedNotification() SubmissionEscalatedNotification()
) )
register_callback( register_callback(
EditSubmission, 'status', 'PENDING', 'ESCALATED', EditSubmission, 'status', 'CLAIMED', 'ESCALATED',
ModerationCacheInvalidation() ModerationCacheInvalidation()
) )
# PhotoSubmission callbacks # PhotoSubmission callbacks (transitions from CLAIMED state)
register_callback( register_callback(
PhotoSubmission, 'status', 'PENDING', 'APPROVED', PhotoSubmission, 'status', 'CLAIMED', 'APPROVED',
SubmissionApprovedNotification() SubmissionApprovedNotification()
) )
register_callback( register_callback(
PhotoSubmission, 'status', 'PENDING', 'APPROVED', PhotoSubmission, 'status', 'CLAIMED', 'APPROVED',
ModerationCacheInvalidation() ModerationCacheInvalidation()
) )
register_callback( register_callback(
PhotoSubmission, 'status', 'PENDING', 'REJECTED', PhotoSubmission, 'status', 'CLAIMED', 'REJECTED',
SubmissionRejectedNotification() SubmissionRejectedNotification()
) )
register_callback( register_callback(
PhotoSubmission, 'status', 'PENDING', 'REJECTED', PhotoSubmission, 'status', 'CLAIMED', 'REJECTED',
ModerationCacheInvalidation() ModerationCacheInvalidation()
) )
register_callback( register_callback(
PhotoSubmission, 'status', 'PENDING', 'ESCALATED', PhotoSubmission, 'status', 'CLAIMED', 'ESCALATED',
SubmissionEscalatedNotification() SubmissionEscalatedNotification()
) )

View File

@@ -22,12 +22,29 @@ EDIT_SUBMISSION_STATUSES = [
'icon': 'clock', 'icon': 'clock',
'css_class': 'bg-yellow-100 text-yellow-800 border-yellow-200', 'css_class': 'bg-yellow-100 text-yellow-800 border-yellow-200',
'sort_order': 1, 'sort_order': 1,
'can_transition_to': ['APPROVED', 'REJECTED', 'ESCALATED'], 'can_transition_to': ['CLAIMED'], # Must be claimed before any action
'requires_moderator': True, 'requires_moderator': True,
'is_actionable': True 'is_actionable': True
}, },
category=ChoiceCategory.STATUS 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( RichChoice(
value="APPROVED", value="APPROVED",
label="Approved", label="Approved",
@@ -36,7 +53,7 @@ EDIT_SUBMISSION_STATUSES = [
'color': 'green', 'color': 'green',
'icon': 'check-circle', 'icon': 'check-circle',
'css_class': 'bg-green-100 text-green-800 border-green-200', 'css_class': 'bg-green-100 text-green-800 border-green-200',
'sort_order': 2, 'sort_order': 3,
'can_transition_to': [], 'can_transition_to': [],
'requires_moderator': True, 'requires_moderator': True,
'is_actionable': False, 'is_actionable': False,
@@ -52,7 +69,7 @@ EDIT_SUBMISSION_STATUSES = [
'color': 'red', 'color': 'red',
'icon': 'x-circle', 'icon': 'x-circle',
'css_class': 'bg-red-100 text-red-800 border-red-200', 'css_class': 'bg-red-100 text-red-800 border-red-200',
'sort_order': 3, 'sort_order': 4,
'can_transition_to': [], 'can_transition_to': [],
'requires_moderator': True, 'requires_moderator': True,
'is_actionable': False, 'is_actionable': False,
@@ -68,7 +85,7 @@ EDIT_SUBMISSION_STATUSES = [
'color': 'purple', 'color': 'purple',
'icon': 'arrow-up', 'icon': 'arrow-up',
'css_class': 'bg-purple-100 text-purple-800 border-purple-200', 'css_class': 'bg-purple-100 text-purple-800 border-purple-200',
'sort_order': 4, 'sort_order': 5,
'can_transition_to': ['APPROVED', 'REJECTED'], 'can_transition_to': ['APPROVED', 'REJECTED'],
'requires_moderator': True, 'requires_moderator': True,
'is_actionable': 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" 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): class Meta(TrackedModel.Meta):
verbose_name = "Edit Submission" verbose_name = "Edit Submission"
verbose_name_plural = "Edit Submissions" 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)""" """Get the final changes to apply (moderator changes if available, otherwise original changes)"""
return self.moderator_changes or self.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]: def approve(self, moderator: UserType, user=None) -> Optional[models.Model]:
""" """
Approve this submission and apply the changes. Approve this submission and apply the changes.
@@ -204,9 +265,17 @@ class EditSubmission(StateMachineMixin, TrackedModel):
ValueError: If submission cannot be approved ValueError: If submission cannot be approved
ValidationError: If the data is invalid ValidationError: If the data is invalid
""" """
from django.core.exceptions import ValidationError
# Use user parameter if provided (FSM convention) # Use user parameter if provided (FSM convention)
approver = user or moderator 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() model_class = self.content_type.model_class()
if not model_class: if not model_class:
raise ValueError("Could not resolve model class") raise ValueError("Could not resolve model class")
@@ -263,9 +332,17 @@ class EditSubmission(StateMachineMixin, TrackedModel):
reason: Reason for rejection reason: Reason for rejection
user: Alternative parameter for FSM compatibility user: Alternative parameter for FSM compatibility
""" """
from django.core.exceptions import ValidationError
# Use user parameter if provided (FSM convention) # Use user parameter if provided (FSM convention)
rejecter = user or moderator 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 # Use FSM transition to update status
self.transition_to_rejected(user=rejecter) self.transition_to_rejected(user=rejecter)
self.handled_by = rejecter self.handled_by = rejecter
@@ -283,9 +360,17 @@ class EditSubmission(StateMachineMixin, TrackedModel):
reason: Reason for escalation reason: Reason for escalation
user: Alternative parameter for FSM compatibility user: Alternative parameter for FSM compatibility
""" """
from django.core.exceptions import ValidationError
# Use user parameter if provided (FSM convention) # Use user parameter if provided (FSM convention)
escalator = user or moderator 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 # Use FSM transition to update status
self.transition_to_escalated(user=escalator) self.transition_to_escalated(user=escalator)
self.handled_by = escalator self.handled_by = escalator
@@ -747,6 +832,19 @@ class PhotoSubmission(StateMachineMixin, TrackedModel):
help_text="Notes from the moderator about this photo submission", 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): class Meta(TrackedModel.Meta):
verbose_name = "Photo Submission" verbose_name = "Photo Submission"
verbose_name_plural = "Photo Submissions" verbose_name_plural = "Photo Submissions"
@@ -759,6 +857,54 @@ class PhotoSubmission(StateMachineMixin, TrackedModel):
def __str__(self) -> str: def __str__(self) -> str:
return f"Photo submission by {self.user.username} for {self.content_object}" 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: def approve(self, moderator: UserType = None, notes: str = "", user=None) -> None:
""" """
Approve the photo submission. Approve the photo submission.
@@ -771,10 +917,17 @@ class PhotoSubmission(StateMachineMixin, TrackedModel):
""" """
from apps.parks.models.media import ParkPhoto from apps.parks.models.media import ParkPhoto
from apps.rides.models.media import RidePhoto from apps.rides.models.media import RidePhoto
from django.core.exceptions import ValidationError
# Use user parameter if provided (FSM convention) # Use user parameter if provided (FSM convention)
approver = user or moderator 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 # Determine the correct photo model based on the content type
model_class = self.content_type.model_class() model_class = self.content_type.model_class()
if model_class.__name__ == "Park": if model_class.__name__ == "Park":
@@ -810,9 +963,17 @@ class PhotoSubmission(StateMachineMixin, TrackedModel):
notes: Rejection reason notes: Rejection reason
user: Alternative parameter for FSM compatibility user: Alternative parameter for FSM compatibility
""" """
from django.core.exceptions import ValidationError
# Use user parameter if provided (FSM convention) # Use user parameter if provided (FSM convention)
rejecter = user or moderator 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 # Use FSM transition to update status
self.transition_to_rejected(user=rejecter) self.transition_to_rejected(user=rejecter)
self.handled_by = rejecter # type: ignore self.handled_by = rejecter # type: ignore
@@ -839,9 +1000,17 @@ class PhotoSubmission(StateMachineMixin, TrackedModel):
notes: Escalation reason notes: Escalation reason
user: Alternative parameter for FSM compatibility user: Alternative parameter for FSM compatibility
""" """
from django.core.exceptions import ValidationError
# Use user parameter if provided (FSM convention) # Use user parameter if provided (FSM convention)
escalator = user or moderator 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 # Use FSM transition to update status
self.transition_to_escalated(user=escalator) self.transition_to_escalated(user=escalator)
self.handled_by = escalator # type: ignore self.handled_by = escalator # type: ignore

View File

@@ -22,6 +22,7 @@ from .models import (
ModerationAction, ModerationAction,
BulkOperation, BulkOperation,
EditSubmission, EditSubmission,
PhotoSubmission,
) )
User = get_user_model() User = get_user_model()
@@ -65,6 +66,7 @@ class EditSubmissionSerializer(serializers.ModelSerializer):
"""Serializer for EditSubmission with UI metadata for Nuxt frontend.""" """Serializer for EditSubmission with UI metadata for Nuxt frontend."""
submitted_by = UserBasicSerializer(source="user", read_only=True) submitted_by = UserBasicSerializer(source="user", read_only=True)
claimed_by = UserBasicSerializer(read_only=True)
content_type_name = serializers.CharField( content_type_name = serializers.CharField(
source="content_type.model", read_only=True source="content_type.model", read_only=True
) )
@@ -91,6 +93,8 @@ class EditSubmissionSerializer(serializers.ModelSerializer):
"rejection_reason", "rejection_reason",
"submitted_by", "submitted_by",
"reviewed_by", "reviewed_by",
"claimed_by",
"claimed_at",
"created_at", "created_at",
"updated_at", "updated_at",
"time_since_created", "time_since_created",
@@ -100,6 +104,8 @@ class EditSubmissionSerializer(serializers.ModelSerializer):
"created_at", "created_at",
"updated_at", "updated_at",
"submitted_by", "submitted_by",
"claimed_by",
"claimed_at",
"status_color", "status_color",
"status_icon", "status_icon",
"status_display", "status_display",
@@ -111,6 +117,7 @@ class EditSubmissionSerializer(serializers.ModelSerializer):
"""Return hex color based on status for UI badges.""" """Return hex color based on status for UI badges."""
colors = { colors = {
"PENDING": "#f59e0b", # Amber "PENDING": "#f59e0b", # Amber
"CLAIMED": "#3b82f6", # Blue
"APPROVED": "#10b981", # Emerald "APPROVED": "#10b981", # Emerald
"REJECTED": "#ef4444", # Red "REJECTED": "#ef4444", # Red
"ESCALATED": "#8b5cf6", # Violet "ESCALATED": "#8b5cf6", # Violet
@@ -121,6 +128,7 @@ class EditSubmissionSerializer(serializers.ModelSerializer):
"""Return Heroicons icon name based on status.""" """Return Heroicons icon name based on status."""
icons = { icons = {
"PENDING": "heroicons:clock", "PENDING": "heroicons:clock",
"CLAIMED": "heroicons:user-circle",
"APPROVED": "heroicons:check-circle", "APPROVED": "heroicons:check-circle",
"REJECTED": "heroicons:x-circle", "REJECTED": "heroicons:x-circle",
"ESCALATED": "heroicons:arrow-up-circle", "ESCALATED": "heroicons:arrow-up-circle",
@@ -148,6 +156,9 @@ class EditSubmissionListSerializer(serializers.ModelSerializer):
submitted_by_username = serializers.CharField( submitted_by_username = serializers.CharField(
source="user.username", read_only=True 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( content_type_name = serializers.CharField(
source="content_type.model", read_only=True source="content_type.model", read_only=True
) )
@@ -162,6 +173,8 @@ class EditSubmissionListSerializer(serializers.ModelSerializer):
"content_type_name", "content_type_name",
"object_id", "object_id",
"submitted_by_username", "submitted_by_username",
"claimed_by_username",
"claimed_at",
"status_color", "status_color",
"status_icon", "status_icon",
"created_at", "created_at",
@@ -171,6 +184,7 @@ class EditSubmissionListSerializer(serializers.ModelSerializer):
def get_status_color(self, obj) -> str: def get_status_color(self, obj) -> str:
colors = { colors = {
"PENDING": "#f59e0b", "PENDING": "#f59e0b",
"CLAIMED": "#3b82f6",
"APPROVED": "#10b981", "APPROVED": "#10b981",
"REJECTED": "#ef4444", "REJECTED": "#ef4444",
"ESCALATED": "#8b5cf6", "ESCALATED": "#8b5cf6",
@@ -180,6 +194,7 @@ class EditSubmissionListSerializer(serializers.ModelSerializer):
def get_status_icon(self, obj) -> str: def get_status_icon(self, obj) -> str:
icons = { icons = {
"PENDING": "heroicons:clock", "PENDING": "heroicons:clock",
"CLAIMED": "heroicons:user-circle",
"APPROVED": "heroicons:check-circle", "APPROVED": "heroicons:check-circle",
"REJECTED": "heroicons:x-circle", "REJECTED": "heroicons:x-circle",
"ESCALATED": "heroicons:arrow-up-circle", "ESCALATED": "heroicons:arrow-up-circle",
@@ -911,3 +926,90 @@ class StateLogSerializer(serializers.ModelSerializer):
] ]
read_only_fields = fields 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 This module provides signal handlers that execute when moderation
models (EditSubmission, PhotoSubmission, ModerationReport, etc.) models (EditSubmission, PhotoSubmission, ModerationReport, etc.)
undergo state transitions. 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 import logging
from django.conf import settings from django.conf import settings
from django.dispatch import receiver from django.dispatch import receiver, Signal
from apps.core.state_machine.signals import ( from apps.core.state_machine.signals import (
post_state_transition, post_state_transition,
@@ -20,6 +25,71 @@ from apps.core.state_machine.signals import (
logger = logging.getLogger(__name__) 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): def handle_submission_approved(instance, source, target, user, context=None, **kwargs):
""" """
Handle submission approval transitions. Handle submission approval transitions.
@@ -255,6 +325,66 @@ def _finalize_bulk_operation(instance, success):
logger.warning(f"Failed to finalize bulk operation: {e}") 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 # Signal handler registration
def register_moderation_signal_handlers(): def register_moderation_signal_handlers():
@@ -320,7 +450,41 @@ def register_moderation_signal_handlers():
handle_bulk_operation_status, stage='post' 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") logger.info("Registered moderation signal handlers")
except ImportError as e: except ImportError as e:
logger.warning(f"Could not register moderation signal handlers: {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, ModerationActionViewSet,
BulkOperationViewSet, BulkOperationViewSet,
UserModerationViewSet, UserModerationViewSet,
EditSubmissionViewSet,
PhotoSubmissionViewSet,
) )
from .sse import ModerationSSEView, ModerationSSETestView
from apps.core.views.views import FSMTransitionView 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"actions", ModerationActionViewSet, basename="moderation-actions")
router.register(r"bulk-operations", BulkOperationViewSet, basename="bulk-operations") router.register(r"bulk-operations", BulkOperationViewSet, basename="bulk-operations")
router.register(r"users", UserModerationViewSet, basename="user-moderation") 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" app_name = "moderation"
# FSM transition convenience URLs for moderation models # FSM transition convenience URLs for moderation models
fsm_transition_patterns = [ fsm_transition_patterns = [
# EditSubmission transitions # EditSubmission transitions
@@ -161,9 +171,17 @@ html_patterns = [
path("history/", HistoryPageView.as_view(), name="history"), 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 = [ urlpatterns = [
# HTML page views # HTML page views
*html_patterns, *html_patterns,
# SSE endpoints
*sse_patterns,
# Include all router URLs (API endpoints) # Include all router URLs (API endpoints)
path("api/", include(router.urls)), path("api/", include(router.urls)),
# FSM transition convenience endpoints # FSM transition convenience endpoints

View File

@@ -34,6 +34,8 @@ from .models import (
ModerationQueue, ModerationQueue,
ModerationAction, ModerationAction,
BulkOperation, BulkOperation,
EditSubmission,
PhotoSubmission,
) )
from .serializers import ( from .serializers import (
ModerationReportSerializer, ModerationReportSerializer,
@@ -47,6 +49,9 @@ from .serializers import (
BulkOperationSerializer, BulkOperationSerializer,
CreateBulkOperationSerializer, CreateBulkOperationSerializer,
UserModerationProfileSerializer, UserModerationProfileSerializer,
EditSubmissionSerializer,
EditSubmissionListSerializer,
PhotoSubmissionSerializer,
) )
from .filters import ( from .filters import (
ModerationReportFilter, ModerationReportFilter,
@@ -1166,6 +1171,28 @@ class UserModerationViewSet(viewsets.ViewSet):
# Default serializer for schema generation # Default serializer for schema generation
serializer_class = UserModerationProfileSerializer 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): def retrieve(self, request, pk=None):
"""Get moderation profile for a specific user.""" """Get moderation profile for a specific user."""
try: try:
@@ -1367,3 +1394,345 @@ class UserModerationViewSet(viewsets.ViewSet):
} }
return Response(stats_data) 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" default_auto_field = "django.db.models.BigAutoField"
name = "apps.reviews" name = "apps.reviews"
verbose_name = "User 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 .reviews import RideReview
from .rankings import RideRanking, RidePairComparison, RankingSnapshot from .rankings import RideRanking, RidePairComparison, RankingSnapshot
from .media import RidePhoto from .media import RidePhoto
from .credits import RideCredit
__all__ = [ __all__ = [
# Primary models # Primary models
@@ -24,6 +25,7 @@ __all__ = [
"RideLocation", "RideLocation",
"RideReview", "RideReview",
"RidePhoto", "RidePhoto",
"RideCredit",
# Rankings # Rankings
"RideRanking", "RideRanking",
"RidePairComparison", "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 "django_forwardemail", # New PyPI package for email service
"apps.moderation", "apps.moderation",
"apps.lists", "apps.lists",
"apps.reviews",
"apps.media",
"apps.blog",
"apps.support",
] ]
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS 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, UserProfileCreateInputSerializer,
UserProfileUpdateInputSerializer, UserProfileUpdateInputSerializer,
UserProfileOutputSerializer, UserProfileOutputSerializer,
TopListCreateInputSerializer,
TopListUpdateInputSerializer,
TopListOutputSerializer,
TopListItemCreateInputSerializer,
TopListItemUpdateInputSerializer,
TopListItemOutputSerializer,
) )
from tests.factories import ( from tests.factories import (
@@ -480,35 +475,4 @@ class TestUserProfileUpdateInputSerializer(TestCase):
assert extra_kwargs.get("user", {}).get("read_only") is True 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)