mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-27 12:27:03 -05:00
feat: Add blog, media, and support apps, implement ride credits and image API, and remove toplist feature.
This commit is contained in:
@@ -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).
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
94
backend/apps/accounts/export_service.py
Normal file
94
backend/apps/accounts/export_service.py
Normal 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
|
||||||
@@ -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"},
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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__"
|
|
||||||
|
|||||||
@@ -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)),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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 ===
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
51
backend/apps/api/v1/accounts/views_credits.py
Normal file
51
backend/apps/api/v1/accounts/views_credits.py
Normal 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)
|
||||||
@@ -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)
|
|
||||||
|
|||||||
6
backend/apps/api/v1/images/urls.py
Normal file
6
backend/apps/api/v1/images/urls.py
Normal 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"),
|
||||||
|
]
|
||||||
37
backend/apps/api/v1/images/views.py
Normal file
37
backend/apps/api/v1/images/views.py
Normal 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
|
||||||
|
)
|
||||||
88
backend/apps/api/v1/parks/history_views.py
Normal file
88
backend/apps/api/v1/parks/history_views.py
Normal 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)
|
||||||
162
backend/apps/api/v1/parks/park_reviews_views.py
Normal file
162
backend/apps/api/v1/parks/park_reviews_views.py
Normal 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)
|
||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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"},
|
||||||
|
|||||||
@@ -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",
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 ===
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
171
backend/apps/api/v1/serializers/park_reviews.py
Normal file
171
backend/apps/api/v1/serializers/park_reviews.py
Normal 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"
|
||||||
|
)
|
||||||
47
backend/apps/api/v1/serializers/ride_credits.py
Normal file
47
backend/apps/api/v1/serializers/ride_credits.py
Normal 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)
|
||||||
@@ -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)
|
||||||
|
|||||||
96
backend/apps/api/v1/views/discovery.py
Normal file
96
backend/apps/api/v1/views/discovery.py
Normal 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
|
||||||
0
backend/apps/blog/__init__.py
Normal file
0
backend/apps/blog/__init__.py
Normal file
6
backend/apps/blog/apps.py
Normal file
6
backend/apps/blog/apps.py
Normal 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"
|
||||||
69
backend/apps/blog/migrations/0001_initial.py
Normal file
69
backend/apps/blog/migrations/0001_initial.py
Normal 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"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
0
backend/apps/blog/migrations/__init__.py
Normal file
0
backend/apps/blog/migrations/__init__.py
Normal file
45
backend/apps/blog/models.py
Normal file
45
backend/apps/blog/models.py
Normal 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
|
||||||
60
backend/apps/blog/serializers.py
Normal file
60
backend/apps/blog/serializers.py
Normal 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
11
backend/apps/blog/urls.py
Normal 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)),
|
||||||
|
]
|
||||||
42
backend/apps/blog/views.py
Normal file
42
backend/apps/blog/views.py
Normal 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)
|
||||||
@@ -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__}",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|
||||||
|
|||||||
54
backend/apps/core/tests/test_history.py
Normal file
54
backend/apps/core/tests/test_history.py
Normal 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"
|
||||||
|
|
||||||
53
backend/apps/core/utils/cloudflare.py
Normal file
53
backend/apps/core/utils/cloudflare.py
Normal 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", {})
|
||||||
@@ -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",
|
||||||
|
|||||||
57
backend/apps/media/models.py
Normal file
57
backend/apps/media/models.py
Normal 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}"
|
||||||
62
backend/apps/media/serializers.py
Normal file
62
backend/apps/media/serializers.py
Normal 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
|
||||||
10
backend/apps/media/urls.py
Normal file
10
backend/apps/media/urls.py
Normal 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)),
|
||||||
|
]
|
||||||
23
backend/apps/media/views.py
Normal file
23
backend/apps/media/views.py
Normal 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)
|
||||||
@@ -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()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
201
backend/apps/moderation/migrations/0009_add_claim_fields.py
Normal file
201
backend/apps/moderation/migrations/0009_add_claim_fields.py
Normal 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",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
]
|
||||||
|
|||||||
185
backend/apps/moderation/sse.py
Normal file
185
backend/apps/moderation/sse.py
Normal 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',
|
||||||
|
]
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
176
backend/apps/reviews/migrations/0001_initial.py
Normal file
176
backend/apps/reviews/migrations/0001_initial.py
Normal 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",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
0
backend/apps/reviews/migrations/__init__.py
Normal file
0
backend/apps/reviews/migrations/__init__.py
Normal file
29
backend/apps/reviews/serializers.py
Normal file
29
backend/apps/reviews/serializers.py
Normal 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
|
||||||
30
backend/apps/reviews/signals.py
Normal file
30
backend/apps/reviews/signals.py
Normal 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'])
|
||||||
10
backend/apps/reviews/urls.py
Normal file
10
backend/apps/reviews/urls.py
Normal 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)),
|
||||||
|
]
|
||||||
27
backend/apps/reviews/views.py
Normal file
27
backend/apps/reviews/views.py
Normal 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)
|
||||||
@@ -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",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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",
|
||||||
|
|||||||
55
backend/apps/rides/models/credits.py
Normal file
55
backend/apps/rides/models/credits.py
Normal 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}"
|
||||||
0
backend/apps/support/__init__.py
Normal file
0
backend/apps/support/__init__.py
Normal file
6
backend/apps/support/apps.py
Normal file
6
backend/apps/support/apps.py
Normal 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"
|
||||||
54
backend/apps/support/migrations/0001_initial.py
Normal file
54
backend/apps/support/migrations/0001_initial.py
Normal 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,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
0
backend/apps/support/migrations/__init__.py
Normal file
0
backend/apps/support/migrations/__init__.py
Normal file
48
backend/apps/support/models.py
Normal file
48
backend/apps/support/models.py
Normal 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)
|
||||||
27
backend/apps/support/serializers.py
Normal file
27
backend/apps/support/serializers.py
Normal 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
|
||||||
10
backend/apps/support/urls.py
Normal file
10
backend/apps/support/urls.py
Normal 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)),
|
||||||
|
]
|
||||||
32
backend/apps/support/views.py
Normal file
32
backend/apps/support/views.py
Normal 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()
|
||||||
@@ -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
32
backend/ensure_admin.py
Normal 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()
|
||||||
@@ -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
966
source_docs/COMPONENTS.md
Normal 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)
|
||||||
495
source_docs/DESIGN_SYSTEM.md
Normal file
495
source_docs/DESIGN_SYSTEM.md
Normal 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
1057
source_docs/PAGES.md
Normal file
File diff suppressed because it is too large
Load Diff
244
source_docs/SITE_OVERVIEW.md
Normal file
244
source_docs/SITE_OVERVIEW.md
Normal 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
881
source_docs/USER_FLOWS.md
Normal 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)
|
||||||
Reference in New Issue
Block a user