mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 15:51:08 -05:00
Compare commits
3 Commits
feature/dj
...
february20
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8900716215 | ||
|
|
401449201c | ||
|
|
1ca1362fee |
6
.gitignore
vendored
6
.gitignore
vendored
@@ -355,6 +355,7 @@ cython_debug/
|
|||||||
# Icon must end with two \r
|
# Icon must end with two \r
|
||||||
Icon
|
Icon
|
||||||
|
|
||||||
|
|
||||||
# Thumbnails
|
# Thumbnails
|
||||||
._*
|
._*
|
||||||
|
|
||||||
@@ -373,3 +374,8 @@ Icon
|
|||||||
Network Trash Folder
|
Network Trash Folder
|
||||||
Temporary Items
|
Temporary Items
|
||||||
.apdisk
|
.apdisk
|
||||||
|
backend/.env
|
||||||
|
.env
|
||||||
|
frontend
|
||||||
|
uv.lock
|
||||||
|
.django_tailwind_cli/tailwindcss-macos-arm64-4.1.13
|
||||||
|
|||||||
123
FRONTEND_IMPLEMENTATION_PLAN.md
Normal file
123
FRONTEND_IMPLEMENTATION_PLAN.md
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
# Frontend Implementation Plan - Phase 1 Critical Components
|
||||||
|
|
||||||
|
## Current State Analysis ✅
|
||||||
|
|
||||||
|
### Completed Components
|
||||||
|
- **Authentication System** - Modal-based auth with social integration ✅
|
||||||
|
- **Toast Notification System** - Advanced toast system with animations ✅
|
||||||
|
- **Theme Management** - Working well ✅
|
||||||
|
- **Header Navigation** - Enhanced with modal integration ✅
|
||||||
|
- **Base Template Structure** - Solid foundation ✅
|
||||||
|
- **Basic Alpine.js Components** - Core components implemented ✅
|
||||||
|
|
||||||
|
### Missing Critical Components (Phase 1 - High Priority)
|
||||||
|
|
||||||
|
## 1. Enhanced Search with Autocomplete 🎯
|
||||||
|
**Current**: Basic search exists but lacks autocomplete and advanced features
|
||||||
|
**Needed**:
|
||||||
|
- Debounced search with API integration
|
||||||
|
- Search suggestions dropdown UI
|
||||||
|
- Search result highlighting
|
||||||
|
- Keyboard navigation for search suggestions
|
||||||
|
- Recent searches and popular searches
|
||||||
|
|
||||||
|
## 2. Enhanced Park/Ride Cards 🎯
|
||||||
|
**Current**: Basic card components exist
|
||||||
|
**Needed**:
|
||||||
|
- Sophisticated hover effects and animations
|
||||||
|
- Card interaction states (hover, focus, active)
|
||||||
|
- Loading states for card images
|
||||||
|
- Card action buttons (favorite, share, etc.)
|
||||||
|
- Image lazy loading and error handling
|
||||||
|
|
||||||
|
## 3. User Profile Management 🎯
|
||||||
|
**Current**: Basic profile pages exist
|
||||||
|
**Needed**:
|
||||||
|
- Comprehensive profile editing interface
|
||||||
|
- Avatar upload with preview functionality
|
||||||
|
- Profile sections (basic info, preferences, privacy)
|
||||||
|
- Form validation and error handling
|
||||||
|
- Settings persistence
|
||||||
|
|
||||||
|
## 4. Advanced Filtering System 🎯
|
||||||
|
**Current**: Basic filtering exists
|
||||||
|
**Needed**:
|
||||||
|
- Multi-select filter components
|
||||||
|
- Range slider filters
|
||||||
|
- Date picker filters
|
||||||
|
- URL state synchronization for filters
|
||||||
|
- Filter presets and saved searches
|
||||||
|
|
||||||
|
## 5. Loading States & Skeletons 🎯
|
||||||
|
**Current**: Basic loading indicators
|
||||||
|
**Needed**:
|
||||||
|
- Skeleton loading components
|
||||||
|
- Loading spinners and indicators
|
||||||
|
- Optimistic updates
|
||||||
|
- Loading states for forms and buttons
|
||||||
|
|
||||||
|
## Implementation Priority Order
|
||||||
|
|
||||||
|
### Week 1: Core Interactive Components
|
||||||
|
1. **Enhanced Search Component** (2-3 days)
|
||||||
|
2. **Advanced Card Components** (2-3 days)
|
||||||
|
3. **Loading States System** (1-2 days)
|
||||||
|
|
||||||
|
### Week 2: User Experience Features
|
||||||
|
1. **User Profile Management** (3-4 days)
|
||||||
|
2. **Advanced Filtering System** (3-4 days)
|
||||||
|
|
||||||
|
## Technical Approach
|
||||||
|
|
||||||
|
### 1. Enhanced Search Component
|
||||||
|
```javascript
|
||||||
|
Alpine.data('advancedSearch', () => ({
|
||||||
|
query: '',
|
||||||
|
suggestions: [],
|
||||||
|
recentSearches: [],
|
||||||
|
popularSearches: [],
|
||||||
|
loading: false,
|
||||||
|
showSuggestions: false,
|
||||||
|
selectedIndex: -1,
|
||||||
|
debounceTimer: null,
|
||||||
|
|
||||||
|
// Implementation details...
|
||||||
|
}))
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Enhanced Card Component
|
||||||
|
```javascript
|
||||||
|
Alpine.data('enhancedCard', (cardData) => ({
|
||||||
|
data: cardData,
|
||||||
|
imageLoaded: false,
|
||||||
|
imageError: false,
|
||||||
|
favorited: false,
|
||||||
|
|
||||||
|
// Hover effects, animations, interactions
|
||||||
|
}))
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Skeleton Loading System
|
||||||
|
```html
|
||||||
|
<!-- Skeleton templates for different content types -->
|
||||||
|
<div class="skeleton-card">
|
||||||
|
<div class="skeleton-image"></div>
|
||||||
|
<div class="skeleton-text"></div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Success Metrics
|
||||||
|
- Search response time < 200ms
|
||||||
|
- Card interactions feel smooth (60fps)
|
||||||
|
- Loading states provide clear feedback
|
||||||
|
- User profile updates work seamlessly
|
||||||
|
- Filtering provides instant feedback
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
1. Start with Enhanced Search Component implementation
|
||||||
|
2. Create comprehensive card component system
|
||||||
|
3. Implement skeleton loading system
|
||||||
|
4. Build user profile management interface
|
||||||
|
5. Create advanced filtering system
|
||||||
|
|
||||||
|
This plan focuses on the most impactful user experience improvements that will bring the Django frontend to parity with the React implementation.
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
# Generated by Django 5.2.6 on 2025-09-20 13:40
|
||||||
|
|
||||||
|
import pgtrigger.compiler
|
||||||
|
import pgtrigger.migrations
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("accounts", "0002_remove_toplistitem_insert_insert_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
pgtrigger.migrations.RemoveTrigger(
|
||||||
|
model_name="toplist",
|
||||||
|
name="insert_insert",
|
||||||
|
),
|
||||||
|
pgtrigger.migrations.RemoveTrigger(
|
||||||
|
model_name="toplist",
|
||||||
|
name="update_update",
|
||||||
|
),
|
||||||
|
pgtrigger.migrations.RemoveTrigger(
|
||||||
|
model_name="toplistitem",
|
||||||
|
name="insert_insert",
|
||||||
|
),
|
||||||
|
pgtrigger.migrations.RemoveTrigger(
|
||||||
|
model_name="toplistitem",
|
||||||
|
name="update_update",
|
||||||
|
),
|
||||||
|
pgtrigger.migrations.AddTrigger(
|
||||||
|
model_name="toplist",
|
||||||
|
trigger=pgtrigger.compiler.Trigger(
|
||||||
|
name="insert_insert",
|
||||||
|
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||||
|
func='INSERT INTO "accounts_toplistevent" ("category", "created_at", "description", "id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "title", "updated_at", "user_id") VALUES (NEW."category", NEW."created_at", NEW."description", NEW."id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."title", NEW."updated_at", NEW."user_id"); RETURN NULL;',
|
||||||
|
hash="0b9e68b3aa0d3fb8f50bd832b99b70201d44aa11",
|
||||||
|
operation="INSERT",
|
||||||
|
pgid="pgtrigger_insert_insert_26546",
|
||||||
|
table="accounts_toplist",
|
||||||
|
when="AFTER",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
pgtrigger.migrations.AddTrigger(
|
||||||
|
model_name="toplist",
|
||||||
|
trigger=pgtrigger.compiler.Trigger(
|
||||||
|
name="update_update",
|
||||||
|
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||||
|
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
|
||||||
|
func='INSERT INTO "accounts_toplistevent" ("category", "created_at", "description", "id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "title", "updated_at", "user_id") VALUES (NEW."category", NEW."created_at", NEW."description", NEW."id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."title", NEW."updated_at", NEW."user_id"); RETURN NULL;',
|
||||||
|
hash="3ae1293b8b1fe574bac9f388b60d19613347931e",
|
||||||
|
operation="UPDATE",
|
||||||
|
pgid="pgtrigger_update_update_84849",
|
||||||
|
table="accounts_toplist",
|
||||||
|
when="AFTER",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
pgtrigger.migrations.AddTrigger(
|
||||||
|
model_name="toplistitem",
|
||||||
|
trigger=pgtrigger.compiler.Trigger(
|
||||||
|
name="insert_insert",
|
||||||
|
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||||
|
func='INSERT INTO "accounts_toplistitemevent" ("content_type_id", "created_at", "id", "notes", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "rank", "top_list_id", "updated_at") VALUES (NEW."content_type_id", NEW."created_at", NEW."id", NEW."notes", NEW."object_id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."rank", NEW."top_list_id", NEW."updated_at"); RETURN NULL;',
|
||||||
|
hash="1091ef1cc7668e112916df0c12f222bd25cfe921",
|
||||||
|
operation="INSERT",
|
||||||
|
pgid="pgtrigger_insert_insert_56dfc",
|
||||||
|
table="accounts_toplistitem",
|
||||||
|
when="AFTER",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
pgtrigger.migrations.AddTrigger(
|
||||||
|
model_name="toplistitem",
|
||||||
|
trigger=pgtrigger.compiler.Trigger(
|
||||||
|
name="update_update",
|
||||||
|
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||||
|
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
|
||||||
|
func='INSERT INTO "accounts_toplistitemevent" ("content_type_id", "created_at", "id", "notes", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "rank", "top_list_id", "updated_at") VALUES (NEW."content_type_id", NEW."created_at", NEW."id", NEW."notes", NEW."object_id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."rank", NEW."top_list_id", NEW."updated_at"); RETURN NULL;',
|
||||||
|
hash="81227a3b4af9432d2b868cd8680bee7896da8acc",
|
||||||
|
operation="UPDATE",
|
||||||
|
pgid="pgtrigger_update_update_2b6e3",
|
||||||
|
table="accounts_toplistitem",
|
||||||
|
when="AFTER",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
1
backend/.django_tailwind_cli/source.css
Normal file
1
backend/.django_tailwind_cli/source.css
Normal file
@@ -0,0 +1 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
BIN
backend/.django_tailwind_cli/tailwindcss-macos-arm64-4.1.12
Executable file
BIN
backend/.django_tailwind_cli/tailwindcss-macos-arm64-4.1.12
Executable file
Binary file not shown.
11421
backend/logs/performance.log.1
Normal file
11421
backend/logs/performance.log.1
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,16 +1,21 @@
|
|||||||
# Active Context
|
# Active Context
|
||||||
|
|
||||||
## Current Focus
|
## Current Focus
|
||||||
- Moderation system development and enhancement
|
- Database schema synchronization and fixes
|
||||||
- Dashboard interface improvements
|
- Parks model and pghistory integration
|
||||||
- Submission review workflow
|
- Ensuring model-database consistency
|
||||||
|
|
||||||
## Recent Changes
|
## Recent Changes
|
||||||
Working on moderation system components:
|
Fixed critical database schema mismatch in parks app:
|
||||||
- Dashboard interface
|
- Updated Park model to include operator and property_owner fields
|
||||||
- Submission list views
|
- Added missing owner_id column to parks_parkevent table
|
||||||
- Moderation navigation
|
- Fixed pghistory triggers that were failing due to missing columns
|
||||||
- Content review workflow
|
- Resolved park detail page errors (parks/magic-kingdom/ now working)
|
||||||
|
|
||||||
|
### Schema Updates Made
|
||||||
|
- parks/models.py: Added operator and property_owner ForeignKey fields
|
||||||
|
- parks/migrations/0006_auto_20250920_0944.py: Added owner_id column to parks_parkevent table
|
||||||
|
- Database now properly supports all three ownership relationships: owner, operator, property_owner
|
||||||
|
|
||||||
## Active Files
|
## Active Files
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
# Generated by Django 5.2.6 on 2025-09-20 13:40
|
||||||
|
|
||||||
|
import pgtrigger.compiler
|
||||||
|
import pgtrigger.migrations
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("companies", "0002_alter_company_id_alter_manufacturer_id"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
pgtrigger.migrations.RemoveTrigger(
|
||||||
|
model_name="company",
|
||||||
|
name="insert_insert",
|
||||||
|
),
|
||||||
|
pgtrigger.migrations.RemoveTrigger(
|
||||||
|
model_name="company",
|
||||||
|
name="update_update",
|
||||||
|
),
|
||||||
|
pgtrigger.migrations.RemoveTrigger(
|
||||||
|
model_name="manufacturer",
|
||||||
|
name="insert_insert",
|
||||||
|
),
|
||||||
|
pgtrigger.migrations.RemoveTrigger(
|
||||||
|
model_name="manufacturer",
|
||||||
|
name="update_update",
|
||||||
|
),
|
||||||
|
pgtrigger.migrations.AddTrigger(
|
||||||
|
model_name="company",
|
||||||
|
trigger=pgtrigger.compiler.Trigger(
|
||||||
|
name="insert_insert",
|
||||||
|
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||||
|
func='INSERT INTO "companies_companyevent" ("created_at", "description", "headquarters", "id", "name", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "slug", "total_parks", "total_rides", "updated_at", "website") VALUES (NEW."created_at", NEW."description", NEW."headquarters", NEW."id", NEW."name", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."slug", NEW."total_parks", NEW."total_rides", NEW."updated_at", NEW."website"); RETURN NULL;',
|
||||||
|
hash="413671b13a748fb5f1acd57e8ec4af12ad7ae215",
|
||||||
|
operation="INSERT",
|
||||||
|
pgid="pgtrigger_insert_insert_a4101",
|
||||||
|
table="companies_company",
|
||||||
|
when="AFTER",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
pgtrigger.migrations.AddTrigger(
|
||||||
|
model_name="company",
|
||||||
|
trigger=pgtrigger.compiler.Trigger(
|
||||||
|
name="update_update",
|
||||||
|
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||||
|
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
|
||||||
|
func='INSERT INTO "companies_companyevent" ("created_at", "description", "headquarters", "id", "name", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "slug", "total_parks", "total_rides", "updated_at", "website") VALUES (NEW."created_at", NEW."description", NEW."headquarters", NEW."id", NEW."name", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."slug", NEW."total_parks", NEW."total_rides", NEW."updated_at", NEW."website"); RETURN NULL;',
|
||||||
|
hash="ee3eff1c96e46769347b8463d527668b7ece63c4",
|
||||||
|
operation="UPDATE",
|
||||||
|
pgid="pgtrigger_update_update_3d5ae",
|
||||||
|
table="companies_company",
|
||||||
|
when="AFTER",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
pgtrigger.migrations.AddTrigger(
|
||||||
|
model_name="manufacturer",
|
||||||
|
trigger=pgtrigger.compiler.Trigger(
|
||||||
|
name="insert_insert",
|
||||||
|
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||||
|
func='INSERT INTO "companies_manufacturerevent" ("created_at", "description", "headquarters", "id", "name", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "slug", "total_rides", "total_roller_coasters", "updated_at", "website") VALUES (NEW."created_at", NEW."description", NEW."headquarters", NEW."id", NEW."name", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."slug", NEW."total_rides", NEW."total_roller_coasters", NEW."updated_at", NEW."website"); RETURN NULL;',
|
||||||
|
hash="ac3c4c31aa8dffe569154454a6c4479d189c0f64",
|
||||||
|
operation="INSERT",
|
||||||
|
pgid="pgtrigger_insert_insert_5c0b6",
|
||||||
|
table="companies_manufacturer",
|
||||||
|
when="AFTER",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
pgtrigger.migrations.AddTrigger(
|
||||||
|
model_name="manufacturer",
|
||||||
|
trigger=pgtrigger.compiler.Trigger(
|
||||||
|
name="update_update",
|
||||||
|
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||||
|
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
|
||||||
|
func='INSERT INTO "companies_manufacturerevent" ("created_at", "description", "headquarters", "id", "name", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "slug", "total_rides", "total_roller_coasters", "updated_at", "website") VALUES (NEW."created_at", NEW."description", NEW."headquarters", NEW."id", NEW."name", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."slug", NEW."total_rides", NEW."total_roller_coasters", NEW."updated_at", NEW."website"); RETURN NULL;',
|
||||||
|
hash="c46f36f5811cd843ff61eab3ae77624ae2e69f60",
|
||||||
|
operation="UPDATE",
|
||||||
|
pgid="pgtrigger_update_update_81971",
|
||||||
|
table="companies_manufacturer",
|
||||||
|
when="AFTER",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# Generated by Django 5.2.6 on 2025-09-20 13:40
|
||||||
|
|
||||||
|
import pgtrigger.compiler
|
||||||
|
import pgtrigger.migrations
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("designers", "0002_alter_designer_id"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
pgtrigger.migrations.RemoveTrigger(
|
||||||
|
model_name="designer",
|
||||||
|
name="insert_insert",
|
||||||
|
),
|
||||||
|
pgtrigger.migrations.RemoveTrigger(
|
||||||
|
model_name="designer",
|
||||||
|
name="update_update",
|
||||||
|
),
|
||||||
|
pgtrigger.migrations.AddTrigger(
|
||||||
|
model_name="designer",
|
||||||
|
trigger=pgtrigger.compiler.Trigger(
|
||||||
|
name="insert_insert",
|
||||||
|
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||||
|
func='INSERT INTO "designers_designerevent" ("created_at", "description", "founded_date", "headquarters", "id", "name", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "slug", "updated_at", "website") VALUES (NEW."created_at", NEW."description", NEW."founded_date", NEW."headquarters", NEW."id", NEW."name", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."slug", NEW."updated_at", NEW."website"); RETURN NULL;',
|
||||||
|
hash="876eaa3e1c7cf234f03cc706fa4e5e508ed780db",
|
||||||
|
operation="INSERT",
|
||||||
|
pgid="pgtrigger_insert_insert_9be65",
|
||||||
|
table="designers_designer",
|
||||||
|
when="AFTER",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
pgtrigger.migrations.AddTrigger(
|
||||||
|
model_name="designer",
|
||||||
|
trigger=pgtrigger.compiler.Trigger(
|
||||||
|
name="update_update",
|
||||||
|
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||||
|
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
|
||||||
|
func='INSERT INTO "designers_designerevent" ("created_at", "description", "founded_date", "headquarters", "id", "name", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "slug", "updated_at", "website") VALUES (NEW."created_at", NEW."description", NEW."founded_date", NEW."headquarters", NEW."id", NEW."name", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."slug", NEW."updated_at", NEW."website"); RETURN NULL;',
|
||||||
|
hash="edb092b6a122ca5827740a9afcdc6a885fe69c1c",
|
||||||
|
operation="UPDATE",
|
||||||
|
pgid="pgtrigger_update_update_b5f91",
|
||||||
|
table="designers_designer",
|
||||||
|
when="AFTER",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# Generated by Django 5.2.6 on 2025-09-20 13:40
|
||||||
|
|
||||||
|
import pgtrigger.compiler
|
||||||
|
import pgtrigger.migrations
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("email_service", "0002_alter_emailconfiguration_id"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
pgtrigger.migrations.RemoveTrigger(
|
||||||
|
model_name="emailconfiguration",
|
||||||
|
name="insert_insert",
|
||||||
|
),
|
||||||
|
pgtrigger.migrations.RemoveTrigger(
|
||||||
|
model_name="emailconfiguration",
|
||||||
|
name="update_update",
|
||||||
|
),
|
||||||
|
pgtrigger.migrations.AddTrigger(
|
||||||
|
model_name="emailconfiguration",
|
||||||
|
trigger=pgtrigger.compiler.Trigger(
|
||||||
|
name="insert_insert",
|
||||||
|
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||||
|
func='INSERT INTO "email_service_emailconfigurationevent" ("api_key", "created_at", "from_email", "from_name", "id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "reply_to", "site_id", "updated_at") VALUES (NEW."api_key", NEW."created_at", NEW."from_email", NEW."from_name", NEW."id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."reply_to", NEW."site_id", NEW."updated_at"); RETURN NULL;',
|
||||||
|
hash="f19f3c7f7d904d5f850a2ff1e0bf1312e855c8c0",
|
||||||
|
operation="INSERT",
|
||||||
|
pgid="pgtrigger_insert_insert_08c59",
|
||||||
|
table="email_service_emailconfiguration",
|
||||||
|
when="AFTER",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
pgtrigger.migrations.AddTrigger(
|
||||||
|
model_name="emailconfiguration",
|
||||||
|
trigger=pgtrigger.compiler.Trigger(
|
||||||
|
name="update_update",
|
||||||
|
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||||
|
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
|
||||||
|
func='INSERT INTO "email_service_emailconfigurationevent" ("api_key", "created_at", "from_email", "from_name", "id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "reply_to", "site_id", "updated_at") VALUES (NEW."api_key", NEW."created_at", NEW."from_email", NEW."from_name", NEW."id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."reply_to", NEW."site_id", NEW."updated_at"); RETURN NULL;',
|
||||||
|
hash="e445521baf2cfb51379b2a6be550b4a638d60202",
|
||||||
|
operation="UPDATE",
|
||||||
|
pgid="pgtrigger_update_update_992a4",
|
||||||
|
table="email_service_emailconfiguration",
|
||||||
|
when="AFTER",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# Generated by Django 5.2.6 on 2025-09-20 13:40
|
||||||
|
|
||||||
|
import pgtrigger.compiler
|
||||||
|
import pgtrigger.migrations
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("location", "0002_alter_location_id"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
pgtrigger.migrations.RemoveTrigger(
|
||||||
|
model_name="location",
|
||||||
|
name="insert_insert",
|
||||||
|
),
|
||||||
|
pgtrigger.migrations.RemoveTrigger(
|
||||||
|
model_name="location",
|
||||||
|
name="update_update",
|
||||||
|
),
|
||||||
|
pgtrigger.migrations.AddTrigger(
|
||||||
|
model_name="location",
|
||||||
|
trigger=pgtrigger.compiler.Trigger(
|
||||||
|
name="insert_insert",
|
||||||
|
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||||
|
func='INSERT INTO "location_locationevent" ("city", "content_type_id", "country", "created_at", "id", "latitude", "location_type", "longitude", "name", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "point", "postal_code", "state", "street_address", "updated_at") VALUES (NEW."city", NEW."content_type_id", NEW."country", NEW."created_at", NEW."id", NEW."latitude", NEW."location_type", NEW."longitude", NEW."name", NEW."object_id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."point", NEW."postal_code", NEW."state", NEW."street_address", NEW."updated_at"); RETURN NULL;',
|
||||||
|
hash="8a8f00869cfcaa1a23ab29b3d855e83602172c67",
|
||||||
|
operation="INSERT",
|
||||||
|
pgid="pgtrigger_insert_insert_98cd4",
|
||||||
|
table="location_location",
|
||||||
|
when="AFTER",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
pgtrigger.migrations.AddTrigger(
|
||||||
|
model_name="location",
|
||||||
|
trigger=pgtrigger.compiler.Trigger(
|
||||||
|
name="update_update",
|
||||||
|
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||||
|
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
|
||||||
|
func='INSERT INTO "location_locationevent" ("city", "content_type_id", "country", "created_at", "id", "latitude", "location_type", "longitude", "name", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "point", "postal_code", "state", "street_address", "updated_at") VALUES (NEW."city", NEW."content_type_id", NEW."country", NEW."created_at", NEW."id", NEW."latitude", NEW."location_type", NEW."longitude", NEW."name", NEW."object_id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."point", NEW."postal_code", NEW."state", NEW."street_address", NEW."updated_at"); RETURN NULL;',
|
||||||
|
hash="f3378cb26a5d88aa82c8fae016d46037b530de90",
|
||||||
|
operation="UPDATE",
|
||||||
|
pgid="pgtrigger_update_update_471d2",
|
||||||
|
table="location_location",
|
||||||
|
when="AFTER",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -6,7 +6,7 @@ import sys
|
|||||||
|
|
||||||
def main():
|
def main():
|
||||||
"""Run administrative tasks."""
|
"""Run administrative tasks."""
|
||||||
os***REMOVED***iron.setdefault("DJANGO_SETTINGS_MODULE", "thrillwiki.settings")
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "thrillwiki.settings")
|
||||||
try:
|
try:
|
||||||
from django.core.management import execute_from_command_line
|
from django.core.management import execute_from_command_line
|
||||||
except ImportError as exc:
|
except ImportError as exc:
|
||||||
|
|||||||
52
media/migrations/0003_remove_photo_insert_insert_and_more.py
Normal file
52
media/migrations/0003_remove_photo_insert_insert_and_more.py
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
# Generated by Django 5.2.6 on 2025-09-20 13:40
|
||||||
|
|
||||||
|
import pgtrigger.compiler
|
||||||
|
import pgtrigger.migrations
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("media", "0002_alter_photo_id"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
pgtrigger.migrations.RemoveTrigger(
|
||||||
|
model_name="photo",
|
||||||
|
name="insert_insert",
|
||||||
|
),
|
||||||
|
pgtrigger.migrations.RemoveTrigger(
|
||||||
|
model_name="photo",
|
||||||
|
name="update_update",
|
||||||
|
),
|
||||||
|
pgtrigger.migrations.AddTrigger(
|
||||||
|
model_name="photo",
|
||||||
|
trigger=pgtrigger.compiler.Trigger(
|
||||||
|
name="insert_insert",
|
||||||
|
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||||
|
func='INSERT INTO "media_photoevent" ("alt_text", "caption", "content_type_id", "created_at", "date_taken", "id", "image", "is_approved", "is_primary", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "updated_at", "uploaded_by_id") VALUES (NEW."alt_text", NEW."caption", NEW."content_type_id", NEW."created_at", NEW."date_taken", NEW."id", NEW."image", NEW."is_approved", NEW."is_primary", NEW."object_id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."updated_at", NEW."uploaded_by_id"); RETURN NULL;',
|
||||||
|
hash="c75cf37b6fac8d5593598ba2af194f1f9a692838",
|
||||||
|
operation="INSERT",
|
||||||
|
pgid="pgtrigger_insert_insert_e1ca0",
|
||||||
|
table="media_photo",
|
||||||
|
when="AFTER",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
pgtrigger.migrations.AddTrigger(
|
||||||
|
model_name="photo",
|
||||||
|
trigger=pgtrigger.compiler.Trigger(
|
||||||
|
name="update_update",
|
||||||
|
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||||
|
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
|
||||||
|
func='INSERT INTO "media_photoevent" ("alt_text", "caption", "content_type_id", "created_at", "date_taken", "id", "image", "is_approved", "is_primary", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "updated_at", "uploaded_by_id") VALUES (NEW."alt_text", NEW."caption", NEW."content_type_id", NEW."created_at", NEW."date_taken", NEW."id", NEW."image", NEW."is_approved", NEW."is_primary", NEW."object_id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."updated_at", NEW."uploaded_by_id"); RETURN NULL;',
|
||||||
|
hash="09d9b3bda4d950d7a7104c8f013a93d05025da72",
|
||||||
|
operation="UPDATE",
|
||||||
|
pgid="pgtrigger_update_update_6ff7d",
|
||||||
|
table="media_photo",
|
||||||
|
when="AFTER",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
71
memory-bank/decisions/park-search-improvements.md
Normal file
71
memory-bank/decisions/park-search-improvements.md
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
# Park Search Implementation Improvements
|
||||||
|
|
||||||
|
## Context
|
||||||
|
The park search functionality needed to be updated to follow consistent patterns across the application and strictly adhere to the "NO CUSTOM JS" rule. Previously, search functionality was inconsistent and did not fully utilize built-in framework features.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
Implemented a unified search pattern that:
|
||||||
|
1. Uses only built-in HTMX and Alpine.js features
|
||||||
|
2. Matches location search pattern
|
||||||
|
3. Removes any custom JavaScript files
|
||||||
|
4. Maintains consistency across the application
|
||||||
|
|
||||||
|
### Benefits
|
||||||
|
1. **Simplified Architecture:**
|
||||||
|
- No custom JavaScript files needed
|
||||||
|
- Direct template-based implementation
|
||||||
|
- Reduced maintenance burden
|
||||||
|
- Smaller codebase
|
||||||
|
|
||||||
|
2. **Framework Alignment:**
|
||||||
|
- Uses HTMX for AJAX requests
|
||||||
|
- Uses Alpine.js for state management
|
||||||
|
- All functionality in templates
|
||||||
|
- Follows project patterns
|
||||||
|
|
||||||
|
3. **Better Maintainability:**
|
||||||
|
- Single source of truth in templates
|
||||||
|
- Reduced complexity
|
||||||
|
- Easier to understand
|
||||||
|
- Consistent with other features
|
||||||
|
|
||||||
|
## Implementation Details
|
||||||
|
|
||||||
|
### Template Features
|
||||||
|
1. HTMX Integration:
|
||||||
|
- Debounced search requests (300ms)
|
||||||
|
- Loading indicators
|
||||||
|
- JSON response handling
|
||||||
|
|
||||||
|
2. Alpine.js Usage:
|
||||||
|
- State management in template
|
||||||
|
- Event handling
|
||||||
|
- UI updates
|
||||||
|
- Keyboard interactions
|
||||||
|
|
||||||
|
### Backend Changes
|
||||||
|
1. JSON API:
|
||||||
|
- Consistent response format
|
||||||
|
- Type validation
|
||||||
|
- Limited results (8 items)
|
||||||
|
- Performance optimization
|
||||||
|
|
||||||
|
2. View Updates:
|
||||||
|
- Search filtering
|
||||||
|
- Result formatting
|
||||||
|
- Error handling
|
||||||
|
- State preservation
|
||||||
|
|
||||||
|
## Benefits
|
||||||
|
1. Better adherence to project standards
|
||||||
|
2. Simplified codebase
|
||||||
|
3. Reduced technical debt
|
||||||
|
4. Easier maintenance
|
||||||
|
5. Consistent user experience
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
1. API response format
|
||||||
|
2. Empty search handling
|
||||||
|
3. Field validation
|
||||||
|
4. UI interactions
|
||||||
|
5. State management
|
||||||
24
memory-bank/decisions/search-form-fix.md
Normal file
24
memory-bank/decisions/search-form-fix.md
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Search Form Fix
|
||||||
|
|
||||||
|
## Issue
|
||||||
|
Search results were being duplicated because selecting a suggestion triggered both:
|
||||||
|
1. The suggestions form submission (to /suggest_parks/)
|
||||||
|
2. The filter form submission (to /park_list/)
|
||||||
|
|
||||||
|
## Root Cause
|
||||||
|
The `@search-selected` event handler was submitting the wrong form. It was submitting the suggestions form which has `hx-target="#search-results"` instead of the filter form which has `hx-target="#park-results"`.
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
Update the event handler to submit the filter form instead of the search form. This ensures only one request is made to update the results.
|
||||||
|
|
||||||
|
## Implementation
|
||||||
|
1. Modified the `@search-selected` handler to:
|
||||||
|
- Set the search query in filter form
|
||||||
|
- Submit filter form to update results
|
||||||
|
- Hide suggestions dropdown
|
||||||
|
2. Added proper form IDs and refs
|
||||||
|
|
||||||
|
## Benefits
|
||||||
|
- Eliminates duplicate requests
|
||||||
|
- Maintains correct search behavior
|
||||||
|
- Improves user experience
|
||||||
@@ -1,105 +1,130 @@
|
|||||||
# Park Search Implementation
|
# Park Search Implementation
|
||||||
|
|
||||||
## Architecture
|
## Search Flow
|
||||||
|
|
||||||
The park search functionality uses a combination of:
|
1. **Quick Search (Suggestions)**
|
||||||
- BaseAutocomplete for search suggestions
|
- Endpoint: `suggest_parks/`
|
||||||
- django-htmx for async updates
|
- Shows up to 8 suggestions
|
||||||
- Django filters for advanced filtering
|
- Uses HTMX for real-time updates
|
||||||
|
- 300ms debounce for typing
|
||||||
|
|
||||||
### Components
|
2. **Full Search**
|
||||||
|
- Endpoint: `parks:park_list`
|
||||||
1. **Forms**
|
- Shows all matching results
|
||||||
- `ParkAutocomplete`: Handles search suggestions
|
- Supports view modes (grid/list)
|
||||||
- `ParkSearchForm`: Integrates autocomplete with search form
|
- Integrates with filter system
|
||||||
|
|
||||||
2. **Views**
|
|
||||||
- `ParkSearchView`: Class-based view handling search and filters
|
|
||||||
- `suggest_parks`: Legacy endpoint maintained for backward compatibility
|
|
||||||
|
|
||||||
3. **Templates**
|
|
||||||
- Simplified search UI using autocomplete widget
|
|
||||||
- Integrated loading indicators
|
|
||||||
- Filter form for additional search criteria
|
|
||||||
|
|
||||||
## Implementation Details
|
## Implementation Details
|
||||||
|
|
||||||
### Search Form
|
### Frontend Components
|
||||||
```python
|
- Search input using built-in HTMX and Alpine.js
|
||||||
class ParkSearchForm(forms.Form):
|
```html
|
||||||
park = forms.ModelChoiceField(
|
<div x-data="{ query: '', selectedId: null }"
|
||||||
queryset=Park.objects.all(),
|
@search-selected.window="...">
|
||||||
required=False,
|
<form hx-get="..." hx-trigger="input changed delay:300ms">
|
||||||
widget=AutocompleteWidget(
|
<!-- Search input and UI components -->
|
||||||
ac_class=ParkAutocomplete,
|
</form>
|
||||||
attrs={
|
</div>
|
||||||
'class': 'w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white',
|
```
|
||||||
'placeholder': 'Search parks...'
|
- No custom JavaScript required
|
||||||
}
|
- Uses native frameworks' features for:
|
||||||
)
|
- State management (Alpine.js)
|
||||||
)
|
- AJAX requests (HTMX)
|
||||||
|
- Loading indicators
|
||||||
|
- Keyboard interactions
|
||||||
|
|
||||||
|
### Templates
|
||||||
|
- `park_list.html`: Main search interface
|
||||||
|
- `park_suggestions.html`: Partial for search suggestions
|
||||||
|
- `park_list_item.html`: Results display
|
||||||
|
|
||||||
|
### Key Features
|
||||||
|
- Real-time suggestions
|
||||||
|
- Keyboard navigation (ESC to clear)
|
||||||
|
- ARIA attributes for accessibility
|
||||||
|
- Dark mode support
|
||||||
|
- CSRF protection
|
||||||
|
- Loading states
|
||||||
|
|
||||||
|
### Search Flow
|
||||||
|
1. User types in search box
|
||||||
|
2. After 300ms debounce, HTMX sends request
|
||||||
|
3. Server returns suggestion list
|
||||||
|
4. User selects item
|
||||||
|
5. Form submits to main list view with filter
|
||||||
|
6. Results update while maintaining view mode
|
||||||
|
|
||||||
|
## Recent Updates (2024-02-22)
|
||||||
|
1. Fixed search page loading issue:
|
||||||
|
- Removed legacy redirect in suggest_parks
|
||||||
|
- Updated search form to use HTMX properly
|
||||||
|
- Added Alpine.js for state management
|
||||||
|
- Improved suggestions UI
|
||||||
|
- Maintained view mode during search
|
||||||
|
|
||||||
|
2. Security:
|
||||||
|
- CSRF protection on all forms
|
||||||
|
- Input sanitization
|
||||||
|
- Proper parameter handling
|
||||||
|
|
||||||
|
3. Performance:
|
||||||
|
- 300ms debounce on typing
|
||||||
|
- Limit suggestions to 8 items
|
||||||
|
- Efficient query optimization
|
||||||
|
|
||||||
|
4. Accessibility:
|
||||||
|
- ARIA labels and roles
|
||||||
|
- Keyboard navigation
|
||||||
|
- Proper focus management
|
||||||
|
- Screen reader support
|
||||||
|
|
||||||
|
## API Response Format
|
||||||
|
|
||||||
|
### Suggestions Endpoint (`/parks/suggest_parks/`)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "string",
|
||||||
|
"name": "string",
|
||||||
|
"status": "string",
|
||||||
|
"location": "string",
|
||||||
|
"url": "string"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Autocomplete
|
### Field Details
|
||||||
```python
|
- `id`: Database ID (string format)
|
||||||
class ParkAutocomplete(BaseAutocomplete):
|
- `name`: Park name
|
||||||
model = Park
|
- `status`: Formatted status display (e.g., "Operating")
|
||||||
search_attrs = ['name']
|
- `location`: Formatted location string
|
||||||
|
- `url`: Full detail page URL
|
||||||
|
|
||||||
def get_search_results(self, search):
|
## Test Coverage
|
||||||
return (get_base_park_queryset()
|
|
||||||
.filter(name__icontains=search)
|
|
||||||
.select_related('owner')
|
|
||||||
.order_by('name'))
|
|
||||||
```
|
|
||||||
|
|
||||||
### View Integration
|
### API Tests
|
||||||
```python
|
- JSON format validation
|
||||||
class ParkSearchView(TemplateView):
|
- Empty search handling
|
||||||
template_name = "parks/park_list.html"
|
- Field type checking
|
||||||
|
- Result limit verification
|
||||||
|
- Response structure
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
### UI Integration Tests
|
||||||
context = super().get_context_data(**kwargs)
|
- View mode persistence
|
||||||
context['search_form'] = ParkSearchForm(self.request.GET)
|
- Loading state verification
|
||||||
# ... filter handling ...
|
- Error handling
|
||||||
return context
|
- Keyboard interaction
|
||||||
```
|
|
||||||
|
|
||||||
## Features
|
### Data Format Tests
|
||||||
|
- Location string formatting
|
||||||
|
- Status display formatting
|
||||||
|
- URL generation
|
||||||
|
- Field type validation
|
||||||
|
|
||||||
1. **Security**
|
### Performance Tests
|
||||||
- Tiered access control:
|
- Debounce functionality
|
||||||
* Public basic search
|
- Result limiting (8 items)
|
||||||
* Authenticated users get autocomplete
|
- Query optimization
|
||||||
* Protected endpoints via settings
|
- Response timing
|
||||||
- CSRF protection
|
|
||||||
- Input validation
|
|
||||||
|
|
||||||
2. **Real-time Search**
|
|
||||||
- Debounced input handling
|
|
||||||
- Instant results display
|
|
||||||
- Loading indicators
|
|
||||||
|
|
||||||
3. **Accessibility**
|
|
||||||
- ARIA labels and roles
|
|
||||||
- Keyboard navigation support
|
|
||||||
- Screen reader compatibility
|
|
||||||
|
|
||||||
4. **Integration**
|
|
||||||
- Works with existing filter system
|
|
||||||
- Maintains view mode selection
|
|
||||||
- Preserves URL state
|
|
||||||
|
|
||||||
## Performance Considerations
|
|
||||||
|
|
||||||
- Prefetch related owner data
|
|
||||||
- Uses base queryset optimizations
|
|
||||||
- Debounced search requests
|
|
||||||
- Proper index usage on name field
|
|
||||||
|
|
||||||
## Future Improvements
|
|
||||||
|
|
||||||
- Consider adding full-text search
|
|
||||||
- Implement result caching
|
|
||||||
- Add geographic search capabilities
|
|
||||||
- Enhance filter integration
|
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
# Generated by Django 5.2.6 on 2025-09-20 13:40
|
||||||
|
|
||||||
|
import pgtrigger.compiler
|
||||||
|
import pgtrigger.migrations
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("moderation", "0002_remove_editsubmission_insert_insert_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
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",
|
||||||
|
),
|
||||||
|
pgtrigger.migrations.AddTrigger(
|
||||||
|
model_name="editsubmission",
|
||||||
|
trigger=pgtrigger.compiler.Trigger(
|
||||||
|
name="insert_insert",
|
||||||
|
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||||
|
func='INSERT INTO "moderation_editsubmissionevent" ("changes", "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."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="0e394e419ba234dd23cb0f4f6567611ad71f2a38",
|
||||||
|
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", "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."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="315b76df75a52d610d3d0857fd5821101e551410",
|
||||||
|
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", "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", "status", "updated_at", "user_id") VALUES (NEW."caption", 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", NEW."status", NEW."updated_at", NEW."user_id"); RETURN NULL;',
|
||||||
|
hash="e967ea629575f6b26892db225b40add9a1558cfb",
|
||||||
|
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", "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", "status", "updated_at", "user_id") VALUES (NEW."caption", 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", NEW."status", NEW."updated_at", NEW."user_id"); RETURN NULL;',
|
||||||
|
hash="b7a97f4e8f90569a90fc4c35cc85e601ff25f0d9",
|
||||||
|
operation="UPDATE",
|
||||||
|
pgid="pgtrigger_update_update_9c311",
|
||||||
|
table="moderation_photosubmission",
|
||||||
|
when="AFTER",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
# Generated by Django 5.2.6 on 2025-09-20 13:40
|
||||||
|
|
||||||
|
import pgtrigger.compiler
|
||||||
|
import pgtrigger.migrations
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("parks", "0003_alter_park_id_alter_parkarea_id_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
pgtrigger.migrations.RemoveTrigger(
|
||||||
|
model_name="park",
|
||||||
|
name="insert_insert",
|
||||||
|
),
|
||||||
|
pgtrigger.migrations.RemoveTrigger(
|
||||||
|
model_name="park",
|
||||||
|
name="update_update",
|
||||||
|
),
|
||||||
|
pgtrigger.migrations.RemoveTrigger(
|
||||||
|
model_name="parkarea",
|
||||||
|
name="insert_insert",
|
||||||
|
),
|
||||||
|
pgtrigger.migrations.RemoveTrigger(
|
||||||
|
model_name="parkarea",
|
||||||
|
name="update_update",
|
||||||
|
),
|
||||||
|
pgtrigger.migrations.AddTrigger(
|
||||||
|
model_name="park",
|
||||||
|
trigger=pgtrigger.compiler.Trigger(
|
||||||
|
name="insert_insert",
|
||||||
|
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||||
|
func='INSERT INTO "parks_parkevent" ("average_rating", "closing_date", "coaster_count", "created_at", "description", "id", "name", "opening_date", "operating_season", "owner_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "ride_count", "size_acres", "slug", "status", "updated_at", "website") VALUES (NEW."average_rating", NEW."closing_date", NEW."coaster_count", NEW."created_at", NEW."description", NEW."id", NEW."name", NEW."opening_date", NEW."operating_season", NEW."owner_id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."ride_count", NEW."size_acres", NEW."slug", NEW."status", NEW."updated_at", NEW."website"); RETURN NULL;',
|
||||||
|
hash="83eb12a74769e2601a23691085a345c29c9b6f68",
|
||||||
|
operation="INSERT",
|
||||||
|
pgid="pgtrigger_insert_insert_66883",
|
||||||
|
table="parks_park",
|
||||||
|
when="AFTER",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
pgtrigger.migrations.AddTrigger(
|
||||||
|
model_name="park",
|
||||||
|
trigger=pgtrigger.compiler.Trigger(
|
||||||
|
name="update_update",
|
||||||
|
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||||
|
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
|
||||||
|
func='INSERT INTO "parks_parkevent" ("average_rating", "closing_date", "coaster_count", "created_at", "description", "id", "name", "opening_date", "operating_season", "owner_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "ride_count", "size_acres", "slug", "status", "updated_at", "website") VALUES (NEW."average_rating", NEW."closing_date", NEW."coaster_count", NEW."created_at", NEW."description", NEW."id", NEW."name", NEW."opening_date", NEW."operating_season", NEW."owner_id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."ride_count", NEW."size_acres", NEW."slug", NEW."status", NEW."updated_at", NEW."website"); RETURN NULL;',
|
||||||
|
hash="f42a468ec35a2d51abd5c1ae1afa41b300ae0a1b",
|
||||||
|
operation="UPDATE",
|
||||||
|
pgid="pgtrigger_update_update_19f56",
|
||||||
|
table="parks_park",
|
||||||
|
when="AFTER",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
pgtrigger.migrations.AddTrigger(
|
||||||
|
model_name="parkarea",
|
||||||
|
trigger=pgtrigger.compiler.Trigger(
|
||||||
|
name="insert_insert",
|
||||||
|
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||||
|
func='INSERT INTO "parks_parkareaevent" ("closing_date", "created_at", "description", "id", "name", "opening_date", "park_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "slug", "updated_at") VALUES (NEW."closing_date", NEW."created_at", NEW."description", NEW."id", NEW."name", NEW."opening_date", NEW."park_id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."slug", NEW."updated_at"); RETURN NULL;',
|
||||||
|
hash="fa64ee07f872bf2214b2c1b638b028429752bac4",
|
||||||
|
operation="INSERT",
|
||||||
|
pgid="pgtrigger_insert_insert_13457",
|
||||||
|
table="parks_parkarea",
|
||||||
|
when="AFTER",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
pgtrigger.migrations.AddTrigger(
|
||||||
|
model_name="parkarea",
|
||||||
|
trigger=pgtrigger.compiler.Trigger(
|
||||||
|
name="update_update",
|
||||||
|
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||||
|
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
|
||||||
|
func='INSERT INTO "parks_parkareaevent" ("closing_date", "created_at", "description", "id", "name", "opening_date", "park_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "slug", "updated_at") VALUES (NEW."closing_date", NEW."created_at", NEW."description", NEW."id", NEW."name", NEW."opening_date", NEW."park_id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."slug", NEW."updated_at"); RETURN NULL;',
|
||||||
|
hash="59fa84527a4fd0fa51685058b6037fa22163a095",
|
||||||
|
operation="UPDATE",
|
||||||
|
pgid="pgtrigger_update_update_6e5aa",
|
||||||
|
table="parks_parkarea",
|
||||||
|
when="AFTER",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
12
parks/migrations/0005_auto_20250920_0943.py
Normal file
12
parks/migrations/0005_auto_20250920_0943.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# Generated by Django 5.2.6 on 2025-09-20 13:43
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("parks", "0004_remove_park_insert_insert_remove_park_update_update_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = []
|
||||||
17
parks/migrations/0006_auto_20250920_0944.py
Normal file
17
parks/migrations/0006_auto_20250920_0944.py
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# Generated by Django 5.2.6 on 2025-09-20 13:44
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("parks", "0005_auto_20250920_0943"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunSQL(
|
||||||
|
"ALTER TABLE parks_parkevent ADD COLUMN IF NOT EXISTS owner_id INTEGER;",
|
||||||
|
reverse_sql="ALTER TABLE parks_parkevent DROP COLUMN IF EXISTS owner_id;"
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -57,6 +57,12 @@ class Park(TrackedModel):
|
|||||||
owner = models.ForeignKey(
|
owner = models.ForeignKey(
|
||||||
Company, on_delete=models.SET_NULL, null=True, blank=True, related_name="parks"
|
Company, on_delete=models.SET_NULL, null=True, blank=True, related_name="parks"
|
||||||
)
|
)
|
||||||
|
operator = models.ForeignKey(
|
||||||
|
Company, on_delete=models.SET_NULL, null=True, blank=True, related_name="operated_parks"
|
||||||
|
)
|
||||||
|
property_owner = models.ForeignKey(
|
||||||
|
Company, on_delete=models.SET_NULL, null=True, blank=True, related_name="owned_properties"
|
||||||
|
)
|
||||||
photos = GenericRelation(Photo, related_query_name="park")
|
photos = GenericRelation(Photo, related_query_name="park")
|
||||||
areas: models.Manager['ParkArea'] # Type hint for reverse relation
|
areas: models.Manager['ParkArea'] # Type hint for reverse relation
|
||||||
rides: models.Manager['Ride'] # Type hint for reverse relation from rides app
|
rides: models.Manager['Ride'] # Type hint for reverse relation from rides app
|
||||||
|
|||||||
@@ -47,25 +47,49 @@
|
|||||||
{% block filter_section %}
|
{% block filter_section %}
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<div class="max-w-3xl mx-auto relative mb-8">
|
<div class="max-w-3xl mx-auto relative mb-8">
|
||||||
<div class="w-full relative">
|
<div class="w-full relative"
|
||||||
<form hx-get="{% url 'parks:park_list' %}"
|
x-data="{ query: '', selectedId: null }"
|
||||||
hx-target="#park-results"
|
@search-selected.window="
|
||||||
hx-push-url="true"
|
query = $event.detail;
|
||||||
hx-trigger="change from:.park-search">
|
selectedId = $event.target.value;
|
||||||
{% csrf_token %}
|
$refs.filterForm.querySelector('input[name=search]').value = query;
|
||||||
{{ search_form.park }}
|
$refs.filterForm.submit();
|
||||||
|
query = '';
|
||||||
|
">
|
||||||
|
<form hx-get="{% url 'parks:suggest_parks' %}"
|
||||||
|
hx-target="#search-results"
|
||||||
|
hx-trigger="input changed delay:300ms"
|
||||||
|
hx-indicator="#search-indicator"
|
||||||
|
x-ref="searchForm">
|
||||||
|
<div class="relative">
|
||||||
|
<input type="search"
|
||||||
|
name="search"
|
||||||
|
placeholder="Search parks..."
|
||||||
|
class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||||
|
aria-label="Search parks"
|
||||||
|
aria-controls="search-results"
|
||||||
|
:aria-expanded="query !== ''"
|
||||||
|
x-model="query"
|
||||||
|
@keydown.escape="query = ''">
|
||||||
|
|
||||||
|
<!-- Loading indicator -->
|
||||||
|
<div id="search-indicator"
|
||||||
|
class="htmx-indicator absolute right-3 top-1/2 -translate-y-1/2"
|
||||||
|
role="status"
|
||||||
|
aria-label="Loading search results">
|
||||||
|
<svg class="w-5 h-5 text-gray-400 animate-spin" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none"/>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
|
||||||
|
</svg>
|
||||||
|
<span class="sr-only">Searching...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<!-- Loading indicator -->
|
<div id="search-results"
|
||||||
<div id="search-indicator"
|
class="absolute z-50 mt-1 w-full bg-white dark:bg-gray-800 rounded-md shadow-lg"
|
||||||
class="htmx-indicator absolute right-3 top-3"
|
role="listbox">
|
||||||
role="status"
|
<!-- Search suggestions will be loaded here -->
|
||||||
aria-label="Loading search results">
|
|
||||||
<svg class="h-5 w-5 text-gray-400 animate-spin" viewBox="0 0 24 24" aria-hidden="true">
|
|
||||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none"/>
|
|
||||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
|
|
||||||
</svg>
|
|
||||||
<span class="sr-only">Searching...</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -74,11 +98,13 @@
|
|||||||
<div class="px-4 py-5 sm:p-6">
|
<div class="px-4 py-5 sm:p-6">
|
||||||
<h3 class="text-lg font-medium leading-6 text-gray-900">Filters</h3>
|
<h3 class="text-lg font-medium leading-6 text-gray-900">Filters</h3>
|
||||||
<form id="filter-form"
|
<form id="filter-form"
|
||||||
|
x-ref="filterForm"
|
||||||
hx-get="{% url 'parks:park_list' %}"
|
hx-get="{% url 'parks:park_list' %}"
|
||||||
hx-target="#park-results"
|
hx-target="#park-results"
|
||||||
hx-push-url="true"
|
hx-push-url="true"
|
||||||
hx-trigger="change"
|
hx-trigger="change, submit"
|
||||||
class="mt-4">
|
class="mt-4">
|
||||||
|
<input type="hidden" name="search" value="{{ request.GET.search }}">
|
||||||
{% include "search/components/filter_form.html" with filter=filter %}
|
{% include "search/components/filter_form.html" with filter=filter %}
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
30
parks/templates/parks/partials/park_suggestions.html
Normal file
30
parks/templates/parks/partials/park_suggestions.html
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% if parks %}
|
||||||
|
<div class="py-2">
|
||||||
|
{% for park in parks %}
|
||||||
|
<button class="w-full text-left px-4 py-2 hover:bg-gray-100 focus:bg-gray-100 focus:outline-none dark:hover:bg-gray-700 dark:focus:bg-gray-700"
|
||||||
|
role="option"
|
||||||
|
@click="$dispatch('search-selected', '{{ park.name }}')"
|
||||||
|
value="{{ park.id }}">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<div class="font-medium">{{ park.name }}</div>
|
||||||
|
<div class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{% if park.formatted_location %}
|
||||||
|
{{ park.formatted_location }}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{{ park.get_status_display }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="px-4 py-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
No parks found matching "{{ query }}"
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
@@ -24,24 +24,70 @@ uv run pytest --cov=parks parks/tests/
|
|||||||
|
|
||||||
## Test Coverage
|
## Test Coverage
|
||||||
|
|
||||||
### Unit Tests
|
### Search API Tests
|
||||||
- `test_autocomplete_results`: Validates search result filtering
|
- `test_search_json_format`: Validates API response structure
|
||||||
- `test_search_form_valid`: Ensures form validation works
|
- `test_empty_search_json`: Tests empty search handling
|
||||||
- `test_autocomplete_class`: Checks autocomplete configuration
|
- `test_search_format_validation`: Verifies all required fields and types
|
||||||
- `test_search_with_filters`: Verifies filter integration
|
- `test_suggestion_limit`: Confirms 8-item result limit
|
||||||
|
|
||||||
### Integration Tests
|
### Search Functionality Tests
|
||||||
- `test_empty_search`: Tests default behavior
|
- `test_autocomplete_results`: Validates real-time suggestion filtering
|
||||||
- `test_partial_match_search`: Validates partial text matching
|
- `test_search_with_filters`: Tests filter integration with search
|
||||||
- `test_htmx_request_handling`: Ensures HTMX compatibility
|
- `test_partial_match_search`: Verifies partial text matching works
|
||||||
- `test_view_mode_persistence`: Checks view state management
|
|
||||||
- `test_unauthenticated_access`: Verifies authentication requirements
|
|
||||||
|
|
||||||
### Security Tests
|
### UI Integration Tests
|
||||||
Parks search implements a tiered access approach:
|
- `test_view_mode_persistence`: Ensures view mode is maintained
|
||||||
- Basic search is public
|
- `test_empty_search`: Tests default state behavior
|
||||||
- Autocomplete requires authentication
|
- `test_htmx_request_handling`: Validates HTMX interactions
|
||||||
- Configuration set in settings.py: `AUTOCOMPLETE_BLOCK_UNAUTHENTICATED = True`
|
|
||||||
|
### Data Format Tests
|
||||||
|
- Field types validation
|
||||||
|
- Location formatting
|
||||||
|
- Status display formatting
|
||||||
|
- URL generation
|
||||||
|
- Response structure
|
||||||
|
|
||||||
|
### Frontend Integration
|
||||||
|
- HTMX partial updates
|
||||||
|
- Alpine.js state management
|
||||||
|
- Loading indicators
|
||||||
|
- View mode persistence
|
||||||
|
- Keyboard navigation
|
||||||
|
|
||||||
|
### Test Commands
|
||||||
|
```bash
|
||||||
|
# Run all park tests
|
||||||
|
uv run pytest parks/tests/
|
||||||
|
|
||||||
|
# Run search tests specifically
|
||||||
|
uv run pytest parks/tests/test_search.py
|
||||||
|
|
||||||
|
# Run with coverage
|
||||||
|
uv run pytest --cov=parks parks/tests/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Coverage Areas
|
||||||
|
1. Search Functionality:
|
||||||
|
- Suggestion generation
|
||||||
|
- Result filtering
|
||||||
|
- Partial matching
|
||||||
|
- Empty state handling
|
||||||
|
|
||||||
|
2. UI Integration:
|
||||||
|
- HTMX requests
|
||||||
|
- View mode switching
|
||||||
|
- Loading states
|
||||||
|
- Error handling
|
||||||
|
|
||||||
|
3. Performance:
|
||||||
|
- Result limiting
|
||||||
|
- Debouncing
|
||||||
|
- Query optimization
|
||||||
|
|
||||||
|
4. Accessibility:
|
||||||
|
- ARIA attributes
|
||||||
|
- Keyboard controls
|
||||||
|
- Screen reader support
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from django.test import Client
|
|||||||
from parks.models import Park
|
from parks.models import Park
|
||||||
from parks.forms import ParkAutocomplete, ParkSearchForm
|
from parks.forms import ParkAutocomplete, ParkSearchForm
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
class TestParkSearch:
|
class TestParkSearch:
|
||||||
def test_autocomplete_results(self, client: Client):
|
def test_autocomplete_results(self, client: Client):
|
||||||
@@ -15,8 +16,8 @@ class TestParkSearch:
|
|||||||
park3 = Park.objects.create(name="Test Garden")
|
park3 = Park.objects.create(name="Test Garden")
|
||||||
|
|
||||||
# Get autocomplete results
|
# Get autocomplete results
|
||||||
url = reverse('parks:park_list')
|
url = reverse('parks:suggest_parks')
|
||||||
response = client.get(url, {'park': 'Test'})
|
response = client.get(url, {'search': 'Test'})
|
||||||
|
|
||||||
# Check response
|
# Check response
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
@@ -27,7 +28,7 @@ class TestParkSearch:
|
|||||||
|
|
||||||
def test_search_form_valid(self):
|
def test_search_form_valid(self):
|
||||||
"""Test ParkSearchForm validation"""
|
"""Test ParkSearchForm validation"""
|
||||||
form = ParkSearchForm(data={'park': ''})
|
form = ParkSearchForm(data={})
|
||||||
assert form.is_valid()
|
assert form.is_valid()
|
||||||
|
|
||||||
def test_autocomplete_class(self):
|
def test_autocomplete_class(self):
|
||||||
@@ -68,8 +69,8 @@ class TestParkSearch:
|
|||||||
Park.objects.create(name="Adventure World")
|
Park.objects.create(name="Adventure World")
|
||||||
Park.objects.create(name="Water Adventure")
|
Park.objects.create(name="Water Adventure")
|
||||||
|
|
||||||
url = reverse('parks:park_list')
|
url = reverse('parks:suggest_parks')
|
||||||
response = client.get(url, {'park': 'Adv'})
|
response = client.get(url, {'search': 'Adv'})
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
content = response.content.decode()
|
content = response.content.decode()
|
||||||
@@ -80,10 +81,10 @@ class TestParkSearch:
|
|||||||
"""Test HTMX-specific request handling"""
|
"""Test HTMX-specific request handling"""
|
||||||
Park.objects.create(name="Test Park")
|
Park.objects.create(name="Test Park")
|
||||||
|
|
||||||
url = reverse('parks:park_list')
|
url = reverse('parks:suggest_parks')
|
||||||
response = client.get(
|
response = client.get(
|
||||||
url,
|
url,
|
||||||
{'park': 'Test'},
|
{'search': 'Test'},
|
||||||
HTTP_HX_REQUEST='true'
|
HTTP_HX_REQUEST='true'
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -103,17 +104,80 @@ class TestParkSearch:
|
|||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert 'data-view-mode="list"' in response.content.decode()
|
assert 'data-view-mode="list"' in response.content.decode()
|
||||||
|
|
||||||
def test_unauthenticated_access(self, client: Client):
|
def test_suggestion_limit(self, client: Client):
|
||||||
"""Test that unauthorized users can access search but not autocomplete"""
|
"""Test that suggestions are limited to 8 items"""
|
||||||
park = Park.objects.create(name="Test Park")
|
# Create 10 parks
|
||||||
|
for i in range(10):
|
||||||
|
Park.objects.create(name=f"Test Park {i}")
|
||||||
|
|
||||||
# Regular search should work
|
|
||||||
url = reverse('parks:park_list')
|
|
||||||
response = client.get(url, {'park_name': 'Test'})
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert "Test Park" in response.content.decode()
|
|
||||||
|
|
||||||
# Autocomplete should require authentication
|
|
||||||
url = reverse('parks:suggest_parks')
|
url = reverse('parks:suggest_parks')
|
||||||
response = client.get(url, {'search': 'Test'})
|
response = client.get(url, {'search': 'Test'})
|
||||||
assert response.status_code == 302 # Redirects to login
|
|
||||||
|
content = response.content.decode()
|
||||||
|
result_count = content.count('Test Park')
|
||||||
|
assert result_count == 8 # Verify limit is enforced
|
||||||
|
|
||||||
|
def test_search_json_format(self, client: Client):
|
||||||
|
"""Test that search returns properly formatted JSON"""
|
||||||
|
park = Park.objects.create(
|
||||||
|
name="Test Park",
|
||||||
|
status="OPERATING",
|
||||||
|
city="Test City",
|
||||||
|
state="Test State"
|
||||||
|
)
|
||||||
|
|
||||||
|
url = reverse('parks:suggest_parks')
|
||||||
|
response = client.get(url, {'search': 'Test'})
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert 'results' in data
|
||||||
|
assert len(data['results']) == 1
|
||||||
|
|
||||||
|
result = data['results'][0]
|
||||||
|
assert result['id'] == str(park.pk)
|
||||||
|
assert result['name'] == "Test Park"
|
||||||
|
assert result['status'] == "Operating"
|
||||||
|
assert result['location'] == park.formatted_location
|
||||||
|
assert result['url'] == reverse('parks:park_detail', kwargs={'slug': park.slug})
|
||||||
|
|
||||||
|
def test_empty_search_json(self, client: Client):
|
||||||
|
"""Test empty search returns empty results array"""
|
||||||
|
url = reverse('parks:suggest_parks')
|
||||||
|
response = client.get(url, {'search': ''})
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert 'results' in data
|
||||||
|
assert len(data['results']) == 0
|
||||||
|
|
||||||
|
def test_search_format_validation(self, client: Client):
|
||||||
|
"""Test that all fields are properly formatted in search results"""
|
||||||
|
park = Park.objects.create(
|
||||||
|
name="Test Park",
|
||||||
|
status="OPERATING",
|
||||||
|
city="Test City",
|
||||||
|
state="Test State",
|
||||||
|
country="Test Country"
|
||||||
|
)
|
||||||
|
|
||||||
|
expected_fields = {'id', 'name', 'status', 'location', 'url'}
|
||||||
|
|
||||||
|
url = reverse('parks:suggest_parks')
|
||||||
|
response = client.get(url, {'search': 'Test'})
|
||||||
|
data = response.json()
|
||||||
|
result = data['results'][0]
|
||||||
|
|
||||||
|
# Check all expected fields are present
|
||||||
|
assert set(result.keys()) == expected_fields
|
||||||
|
|
||||||
|
# Check field types
|
||||||
|
assert isinstance(result['id'], str)
|
||||||
|
assert isinstance(result['name'], str)
|
||||||
|
assert isinstance(result['status'], str)
|
||||||
|
assert isinstance(result['location'], str)
|
||||||
|
assert isinstance(result['url'], str)
|
||||||
|
|
||||||
|
# Check formatted location includes city and state
|
||||||
|
assert 'Test City' in result['location']
|
||||||
|
assert 'Test State' in result['location']
|
||||||
|
|||||||
@@ -33,11 +33,22 @@ class ParkSearchView(TemplateView):
|
|||||||
|
|
||||||
return context
|
return context
|
||||||
|
|
||||||
def suggest_parks(request: HttpRequest) -> HttpResponse:
|
def suggest_parks(request: HttpRequest) -> JsonResponse:
|
||||||
"""Legacy endpoint for old search UI - redirects to autocomplete."""
|
"""Return park search suggestions as JSON."""
|
||||||
query = request.GET.get('search', '').strip()
|
query = request.GET.get('search', '').strip()
|
||||||
if query:
|
if not query:
|
||||||
return JsonResponse({
|
return JsonResponse({'results': []})
|
||||||
'redirect': f"{reverse('parks:park_list')}?park_name={query}"
|
|
||||||
})
|
queryset = get_base_park_queryset()
|
||||||
return HttpResponse('')
|
filter_instance = ParkFilter({'search': query}, queryset=queryset)
|
||||||
|
parks = filter_instance.qs[:8] # Limit to 8 suggestions
|
||||||
|
|
||||||
|
results = [{
|
||||||
|
'id': str(park.pk),
|
||||||
|
'name': park.name,
|
||||||
|
'status': park.get_status_display(),
|
||||||
|
'location': park.formatted_location or '',
|
||||||
|
'url': reverse('parks:park_detail', kwargs={'slug': park.slug})
|
||||||
|
} for park in parks]
|
||||||
|
|
||||||
|
return JsonResponse({'results': results})
|
||||||
@@ -26,6 +26,7 @@ django_settings = "thrillwiki.settings"
|
|||||||
[project]
|
[project]
|
||||||
name = "thrillwiki"
|
name = "thrillwiki"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"Django>=5.0",
|
"Django>=5.0",
|
||||||
"djangorestframework>=3.14.0",
|
"djangorestframework>=3.14.0",
|
||||||
@@ -58,4 +59,5 @@ dependencies = [
|
|||||||
"pytest-playwright>=0.4.3",
|
"pytest-playwright>=0.4.3",
|
||||||
"django-pghistory>=3.5.2",
|
"django-pghistory>=3.5.2",
|
||||||
"django-htmx-autocomplete>=1.0.5",
|
"django-htmx-autocomplete>=1.0.5",
|
||||||
|
"python-decouple>=3.8",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# Generated by Django 5.2.6 on 2025-09-20 13:40
|
||||||
|
|
||||||
|
import pgtrigger.compiler
|
||||||
|
import pgtrigger.migrations
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("reviews", "0002_alter_review_id"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
pgtrigger.migrations.RemoveTrigger(
|
||||||
|
model_name="review",
|
||||||
|
name="insert_insert",
|
||||||
|
),
|
||||||
|
pgtrigger.migrations.RemoveTrigger(
|
||||||
|
model_name="review",
|
||||||
|
name="update_update",
|
||||||
|
),
|
||||||
|
pgtrigger.migrations.AddTrigger(
|
||||||
|
model_name="review",
|
||||||
|
trigger=pgtrigger.compiler.Trigger(
|
||||||
|
name="insert_insert",
|
||||||
|
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||||
|
func='INSERT INTO "reviews_reviewevent" ("content", "content_type_id", "created_at", "id", "is_published", "moderated_at", "moderated_by_id", "moderation_notes", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "rating", "title", "updated_at", "user_id", "visit_date") VALUES (NEW."content", NEW."content_type_id", NEW."created_at", NEW."id", NEW."is_published", NEW."moderated_at", NEW."moderated_by_id", NEW."moderation_notes", NEW."object_id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."rating", NEW."title", NEW."updated_at", NEW."user_id", NEW."visit_date"); RETURN NULL;',
|
||||||
|
hash="1126891ad95c3c8dc8580ca8b669b6c195960cff",
|
||||||
|
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", "content_type_id", "created_at", "id", "is_published", "moderated_at", "moderated_by_id", "moderation_notes", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "rating", "title", "updated_at", "user_id", "visit_date") VALUES (NEW."content", NEW."content_type_id", NEW."created_at", NEW."id", NEW."is_published", NEW."moderated_at", NEW."moderated_by_id", NEW."moderation_notes", NEW."object_id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."rating", NEW."title", NEW."updated_at", NEW."user_id", NEW."visit_date"); RETURN NULL;',
|
||||||
|
hash="091fc5e3597eddb31fed505798d29859fe8efbe0",
|
||||||
|
operation="UPDATE",
|
||||||
|
pgid="pgtrigger_update_update_b34c8",
|
||||||
|
table="reviews_review",
|
||||||
|
when="AFTER",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Generated by Django 5.2.6 on 2025-09-20 13:40
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("rides", "0006_alter_rideevent_options_alter_ridemodelevent_options_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelTable(
|
||||||
|
name="rideevent",
|
||||||
|
table="rides_rideevent",
|
||||||
|
),
|
||||||
|
migrations.AlterModelTable(
|
||||||
|
name="ridemodelevent",
|
||||||
|
table="rides_ridemodelevent",
|
||||||
|
),
|
||||||
|
]
|
||||||
42
static/js/search.js
Normal file
42
static/js/search.js
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
function parkSearch() {
|
||||||
|
return {
|
||||||
|
query: '',
|
||||||
|
results: [],
|
||||||
|
loading: false,
|
||||||
|
selectedId: null,
|
||||||
|
|
||||||
|
async search() {
|
||||||
|
if (!this.query.trim()) {
|
||||||
|
this.results = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.loading = true;
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/parks/suggest_parks/?search=${encodeURIComponent(this.query)}`);
|
||||||
|
const data = await response.json();
|
||||||
|
this.results = data.results;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Search failed:', error);
|
||||||
|
this.results = [];
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
clear() {
|
||||||
|
this.query = '';
|
||||||
|
this.results = [];
|
||||||
|
this.selectedId = null;
|
||||||
|
},
|
||||||
|
|
||||||
|
selectPark(park) {
|
||||||
|
this.query = park.name;
|
||||||
|
this.selectedId = park.id;
|
||||||
|
this.results = [];
|
||||||
|
|
||||||
|
// Trigger filter update
|
||||||
|
document.getElementById('park-filters').dispatchEvent(new Event('change'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
310
thrillwiki-nextjs-development-prompt.md
Normal file
310
thrillwiki-nextjs-development-prompt.md
Normal file
@@ -0,0 +1,310 @@
|
|||||||
|
# ThrillWiki Next.js Development Prompt
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
Build a comprehensive theme park and ride database platform using Next.js. ThrillWiki is a community-driven platform where enthusiasts can discover parks, explore rides, share reviews, and contribute to a moderated knowledge base about theme parks worldwide.
|
||||||
|
|
||||||
|
## Core Application Domain
|
||||||
|
|
||||||
|
### Primary Entities
|
||||||
|
- **Parks**: Theme parks, amusement parks, water parks with detailed information, locations, operating details
|
||||||
|
- **Rides**: Individual ride installations with technical specifications, manufacturer details, operational status
|
||||||
|
- **Companies**: Manufacturers, operators, designers, property owners with different roles
|
||||||
|
- **Users**: Community members with profiles, preferences, reviews, and top lists
|
||||||
|
- **Reviews**: User-generated content with ratings, media, and moderation workflow
|
||||||
|
- **Locations**: Geographic data for parks and rides with PostGIS-style coordinate handling
|
||||||
|
|
||||||
|
### Key Relationships
|
||||||
|
- Parks contain multiple rides and are operated by companies
|
||||||
|
- Rides belong to parks, have manufacturers/designers, and reference ride models
|
||||||
|
- Ride models are templates created by manufacturers with technical specifications
|
||||||
|
- Users create reviews for parks and rides, maintain top lists, have notification preferences
|
||||||
|
- Companies have multiple roles (manufacturer, operator, designer, property owner)
|
||||||
|
- All content goes through moderation workflows before publication
|
||||||
|
|
||||||
|
## User Personas & Workflows
|
||||||
|
|
||||||
|
### Theme Park Enthusiasts
|
||||||
|
- Browse and discover parks by location, type, and features
|
||||||
|
- Search for specific rides and view detailed technical specifications
|
||||||
|
- Plan park visits with operating information and ride availability
|
||||||
|
- Track personal ride credits and maintain top lists
|
||||||
|
- Read authentic reviews from other enthusiasts
|
||||||
|
|
||||||
|
### Content Contributors
|
||||||
|
- Submit new park and ride information for moderation
|
||||||
|
- Upload photos with proper attribution and categorization
|
||||||
|
- Write detailed reviews with ratings and media attachments
|
||||||
|
- Maintain personal profiles with ride statistics and achievements
|
||||||
|
- Participate in community discussions and rankings
|
||||||
|
|
||||||
|
### Park Industry Professionals
|
||||||
|
- Maintain verified company profiles with official information
|
||||||
|
- Update park operating details, ride status, and announcements
|
||||||
|
- Access analytics and engagement metrics for their properties
|
||||||
|
- Manage official media and promotional content
|
||||||
|
|
||||||
|
## Core Features to Implement
|
||||||
|
|
||||||
|
### 1. Park Discovery & Information System
|
||||||
|
- **Park Listings**: Filterable grid/list views with search, location-based filtering, park type categories
|
||||||
|
- **Park Detail Pages**: Comprehensive information including rides list, operating hours, location maps, photo galleries
|
||||||
|
- **Interactive Maps**: Geographic visualization of parks with clustering, zoom controls, and location-based search
|
||||||
|
- **Advanced Search**: Multi-criteria search across parks, rides, locations, and companies
|
||||||
|
|
||||||
|
### 2. Ride Database & Technical Specifications
|
||||||
|
- **Ride Catalog**: Comprehensive database with manufacturer information, technical specs, operational history
|
||||||
|
- **Ride Detail Pages**: In-depth information including statistics, photos, reviews, and related rides
|
||||||
|
- **Manufacturer Profiles**: Company information with ride model catalogs and installation history
|
||||||
|
- **Technical Comparisons**: Side-by-side ride comparisons with filterable specifications
|
||||||
|
|
||||||
|
### 3. User-Generated Content System
|
||||||
|
- **Review Platform**: Structured review forms with ratings, text, photo uploads, and helpful voting
|
||||||
|
- **Photo Management**: Upload system with automatic optimization, categorization, and attribution tracking
|
||||||
|
- **Top Lists**: Personal ranking systems for rides, parks, and experiences with drag-and-drop reordering
|
||||||
|
- **User Profiles**: Personal statistics, ride credits, achievement tracking, and social features
|
||||||
|
|
||||||
|
### 4. Content Moderation Workflow
|
||||||
|
- **Submission Queue**: Administrative interface for reviewing user-submitted content
|
||||||
|
- **Approval Process**: Multi-stage review with rejection reasons, feedback, and resubmission options
|
||||||
|
- **Quality Control**: Automated checks for duplicate content, inappropriate material, and data validation
|
||||||
|
- **User Management**: Moderation tools for user accounts, banning, and content removal
|
||||||
|
|
||||||
|
### 5. Location & Geographic Services
|
||||||
|
- **Location Search**: Address-based search with autocomplete and geographic boundaries
|
||||||
|
- **Proximity Features**: Find nearby parks, distance calculations, and regional groupings
|
||||||
|
- **Map Integration**: Interactive maps with custom markers, clustering, and detailed overlays
|
||||||
|
- **Geographic Filtering**: Location-based content filtering and discovery
|
||||||
|
|
||||||
|
## Technical Architecture Requirements
|
||||||
|
|
||||||
|
### Frontend Stack
|
||||||
|
- **Next.js 14+** with App Router for modern React development
|
||||||
|
- **TypeScript** for type safety and better developer experience
|
||||||
|
- **Tailwind CSS** for utility-first styling and responsive design
|
||||||
|
- **Shadcn/ui** for consistent, accessible component library
|
||||||
|
- **React Hook Form** with Zod validation for form handling
|
||||||
|
- **TanStack Query** for server state management and caching
|
||||||
|
- **Zustand** for client-side state management
|
||||||
|
- **Next-Auth** for authentication and session management
|
||||||
|
|
||||||
|
### Data Management
|
||||||
|
- **API Integration**: RESTful API consumption with comprehensive error handling
|
||||||
|
- **Image Optimization**: Cloudflare Images integration with multiple variants
|
||||||
|
- **Caching Strategy**: Multi-level caching with SWR patterns and cache invalidation
|
||||||
|
- **Search Implementation**: Client-side and server-side search with debouncing
|
||||||
|
- **Infinite Scrolling**: Performance-optimized pagination for large datasets
|
||||||
|
|
||||||
|
### UI/UX Patterns
|
||||||
|
- **Responsive Design**: Mobile-first approach with tablet and desktop optimizations
|
||||||
|
- **Dark/Light Themes**: User preference-based theming with system detection
|
||||||
|
- **Loading States**: Skeleton screens, progressive loading, and optimistic updates
|
||||||
|
- **Error Boundaries**: Graceful error handling with user-friendly messages
|
||||||
|
- **Accessibility**: WCAG compliance with keyboard navigation and screen reader support
|
||||||
|
|
||||||
|
## Key Components to Build
|
||||||
|
|
||||||
|
### Layout & Navigation
|
||||||
|
- **Header Component**: Logo, navigation menu, user authentication, search bar, theme toggle
|
||||||
|
- **Sidebar Navigation**: Collapsible menu with user context and quick actions
|
||||||
|
- **Footer Component**: Links, social media, legal information, and site statistics
|
||||||
|
- **Breadcrumb Navigation**: Contextual navigation with proper hierarchy
|
||||||
|
|
||||||
|
### Park Components
|
||||||
|
- **ParkCard**: Compact park information with image, basic details, and quick actions
|
||||||
|
- **ParkGrid**: Responsive grid layout with filtering and sorting options
|
||||||
|
- **ParkDetail**: Comprehensive park information with tabbed sections
|
||||||
|
- **ParkMap**: Interactive map showing park location and nearby attractions
|
||||||
|
- **OperatingHours**: Dynamic display of park hours with seasonal variations
|
||||||
|
|
||||||
|
### Ride Components
|
||||||
|
- **RideCard**: Ride preview with key specifications and ratings
|
||||||
|
- **RideList**: Filterable list with sorting and search capabilities
|
||||||
|
- **RideDetail**: Complete ride information with technical specifications
|
||||||
|
- **RideStats**: Visual representation of ride statistics and comparisons
|
||||||
|
- **RidePhotos**: Photo gallery with lightbox and attribution information
|
||||||
|
|
||||||
|
### User Interface Components
|
||||||
|
- **UserProfile**: Personal information, statistics, and preference management
|
||||||
|
- **ReviewForm**: Structured review creation with rating inputs and media upload
|
||||||
|
- **TopListManager**: Drag-and-drop interface for creating and managing rankings
|
||||||
|
- **NotificationCenter**: Real-time notifications with read/unread states
|
||||||
|
- **SearchInterface**: Advanced search with filters, suggestions, and recent searches
|
||||||
|
|
||||||
|
### Content Management
|
||||||
|
- **SubmissionForm**: Content submission interface with validation and preview
|
||||||
|
- **ModerationQueue**: Administrative interface for content review and approval
|
||||||
|
- **PhotoUpload**: Drag-and-drop photo upload with progress tracking and optimization
|
||||||
|
- **ContentEditor**: Rich text editor for descriptions and review content
|
||||||
|
|
||||||
|
## Data Models & API Integration
|
||||||
|
|
||||||
|
### API Endpoints Structure
|
||||||
|
```typescript
|
||||||
|
// Parks API
|
||||||
|
GET /api/parks/ - List parks with filtering and pagination
|
||||||
|
GET /api/parks/{slug}/ - Park details with rides and reviews
|
||||||
|
GET /api/parks/{slug}/rides/ - Rides at specific park
|
||||||
|
GET /api/parks/search/ - Advanced park search
|
||||||
|
|
||||||
|
// Rides API
|
||||||
|
GET /api/rides/ - List rides with filtering
|
||||||
|
GET /api/rides/{park_slug}/{ride_slug}/ - Ride details
|
||||||
|
GET /api/rides/manufacturers/{slug}/ - Manufacturer information
|
||||||
|
GET /api/rides/search/ - Advanced ride search
|
||||||
|
|
||||||
|
// User API
|
||||||
|
GET /api/users/profile/ - Current user profile
|
||||||
|
PUT /api/users/profile/ - Update user profile
|
||||||
|
GET /api/users/{username}/ - Public user profile
|
||||||
|
POST /api/users/reviews/ - Create review
|
||||||
|
|
||||||
|
// Content API
|
||||||
|
POST /api/submissions/ - Submit new content
|
||||||
|
GET /api/submissions/status/ - Check submission status
|
||||||
|
POST /api/photos/upload/ - Upload photos
|
||||||
|
GET /api/moderation/queue/ - Moderation queue (admin)
|
||||||
|
```
|
||||||
|
|
||||||
|
### TypeScript Interfaces
|
||||||
|
```typescript
|
||||||
|
interface Park {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
description: string;
|
||||||
|
status: string;
|
||||||
|
park_type: string;
|
||||||
|
location: ParkLocation;
|
||||||
|
operator: Company;
|
||||||
|
opening_date: string;
|
||||||
|
ride_count: number;
|
||||||
|
coaster_count: number;
|
||||||
|
average_rating: number;
|
||||||
|
banner_image: Photo;
|
||||||
|
card_image: Photo;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Ride {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
description: string;
|
||||||
|
park: Park;
|
||||||
|
category: string;
|
||||||
|
manufacturer: Company;
|
||||||
|
ride_model: RideModel;
|
||||||
|
status: string;
|
||||||
|
opening_date: string;
|
||||||
|
height_requirement: number;
|
||||||
|
average_rating: number;
|
||||||
|
coaster_stats?: RollerCoasterStats;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
display_name: string;
|
||||||
|
profile: UserProfile;
|
||||||
|
role: string;
|
||||||
|
theme_preference: string;
|
||||||
|
privacy_level: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Authentication & User Management
|
||||||
|
|
||||||
|
### Authentication Flow
|
||||||
|
- **Registration**: Email-based signup with verification workflow
|
||||||
|
- **Login**: Username/email login with remember me option
|
||||||
|
- **Social Auth**: Integration with Google, Facebook, Discord for quick signup
|
||||||
|
- **Password Reset**: Secure password reset with email verification
|
||||||
|
- **Two-Factor Auth**: Optional 2FA with authenticator app support
|
||||||
|
|
||||||
|
### User Roles & Permissions
|
||||||
|
- **Regular Users**: Create reviews, manage profiles, submit content
|
||||||
|
- **Verified Contributors**: Trusted users with expedited content approval
|
||||||
|
- **Moderators**: Content review, user management, community oversight
|
||||||
|
- **Administrators**: Full system access, user role management, system configuration
|
||||||
|
|
||||||
|
### Privacy & Security
|
||||||
|
- **Privacy Controls**: Granular privacy settings for profile visibility and data sharing
|
||||||
|
- **Content Moderation**: Automated and manual content review processes
|
||||||
|
- **Data Protection**: GDPR compliance with data export and deletion options
|
||||||
|
- **Security Features**: Login notifications, session management, suspicious activity detection
|
||||||
|
|
||||||
|
## Performance & Optimization
|
||||||
|
|
||||||
|
### Loading & Caching
|
||||||
|
- **Image Optimization**: Cloudflare Images with responsive variants and lazy loading
|
||||||
|
- **API Caching**: Intelligent caching with stale-while-revalidate patterns
|
||||||
|
- **Static Generation**: Pre-generated pages for popular content with ISR
|
||||||
|
- **Code Splitting**: Route-based and component-based code splitting for optimal loading
|
||||||
|
|
||||||
|
### Search & Filtering
|
||||||
|
- **Client-Side Search**: Fast text search with debouncing and result highlighting
|
||||||
|
- **Server-Side Filtering**: Complex filtering with URL state management
|
||||||
|
- **Infinite Scrolling**: Performance-optimized pagination for large datasets
|
||||||
|
- **Search Analytics**: Track popular searches and optimize content discovery
|
||||||
|
|
||||||
|
### Mobile Optimization
|
||||||
|
- **Responsive Design**: Mobile-first approach with touch-friendly interfaces
|
||||||
|
- **Progressive Web App**: PWA features with offline capability and push notifications
|
||||||
|
- **Performance Budgets**: Strict performance monitoring with Core Web Vitals tracking
|
||||||
|
- **Accessibility**: Full keyboard navigation and screen reader compatibility
|
||||||
|
|
||||||
|
## Content Management & Moderation
|
||||||
|
|
||||||
|
### Submission Workflow
|
||||||
|
- **Content Forms**: Structured forms for parks, rides, and reviews with validation
|
||||||
|
- **Draft System**: Save and resume content creation with auto-save functionality
|
||||||
|
- **Preview Mode**: Real-time preview of content before submission
|
||||||
|
- **Submission Tracking**: Status updates and feedback throughout review process
|
||||||
|
|
||||||
|
### Moderation Interface
|
||||||
|
- **Review Queue**: Prioritized queue with filtering and batch operations
|
||||||
|
- **Approval Workflow**: Multi-stage review with detailed feedback options
|
||||||
|
- **Quality Metrics**: Track approval rates, review times, and content quality
|
||||||
|
- **User Reputation**: Contributor scoring system based on submission quality
|
||||||
|
|
||||||
|
### Media Management
|
||||||
|
- **Photo Upload**: Drag-and-drop interface with progress tracking and error handling
|
||||||
|
- **Image Processing**: Automatic optimization, resizing, and format conversion
|
||||||
|
- **Attribution Tracking**: Photographer credits and copyright information
|
||||||
|
- **Bulk Operations**: Batch photo management and organization tools
|
||||||
|
|
||||||
|
## Analytics & Insights
|
||||||
|
|
||||||
|
### User Analytics
|
||||||
|
- **Engagement Tracking**: Page views, time on site, and user journey analysis
|
||||||
|
- **Content Performance**: Popular parks, rides, and reviews with engagement metrics
|
||||||
|
- **Search Analytics**: Popular search terms and content discovery patterns
|
||||||
|
- **User Behavior**: Registration funnels, retention rates, and feature adoption
|
||||||
|
|
||||||
|
### Content Insights
|
||||||
|
- **Submission Metrics**: Content creation rates, approval times, and quality scores
|
||||||
|
- **Review Analytics**: Rating distributions, helpful votes, and review engagement
|
||||||
|
- **Geographic Data**: Popular regions, park visit patterns, and location-based insights
|
||||||
|
- **Trending Content**: Real-time trending parks, rides, and discussions
|
||||||
|
|
||||||
|
## Development Guidelines
|
||||||
|
|
||||||
|
### Code Organization
|
||||||
|
- **Feature-Based Structure**: Organize code by features rather than file types
|
||||||
|
- **Component Library**: Reusable components with Storybook documentation
|
||||||
|
- **Custom Hooks**: Shared logic extraction with proper TypeScript typing
|
||||||
|
- **Utility Functions**: Common operations with comprehensive testing
|
||||||
|
|
||||||
|
### Testing Strategy
|
||||||
|
- **Unit Testing**: Component and utility function testing with Jest and React Testing Library
|
||||||
|
- **Integration Testing**: API integration and user workflow testing
|
||||||
|
- **E2E Testing**: Critical user journeys with Playwright or Cypress
|
||||||
|
- **Performance Testing**: Core Web Vitals monitoring and performance regression testing
|
||||||
|
|
||||||
|
### Deployment & DevOps
|
||||||
|
- **Environment Management**: Development, staging, and production environments
|
||||||
|
- **CI/CD Pipeline**: Automated testing, building, and deployment
|
||||||
|
- **Monitoring**: Error tracking, performance monitoring, and user analytics
|
||||||
|
- **Security**: Regular security audits, dependency updates, and vulnerability scanning
|
||||||
|
|
||||||
|
This comprehensive platform should provide theme park enthusiasts with a rich, engaging experience while maintaining high content quality through effective moderation and community management systems.
|
||||||
@@ -11,6 +11,6 @@ import os
|
|||||||
|
|
||||||
from django.core.asgi import get_asgi_application
|
from django.core.asgi import get_asgi_application
|
||||||
|
|
||||||
os***REMOVED***iron.setdefault("DJANGO_SETTINGS_MODULE", "thrillwiki.settings")
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "thrillwiki.settings")
|
||||||
|
|
||||||
application = get_asgi_application()
|
application = get_asgi_application()
|
||||||
|
|||||||
@@ -180,7 +180,7 @@ SOCIALACCOUNT_PROVIDERS = {
|
|||||||
"google": {
|
"google": {
|
||||||
"APP": {
|
"APP": {
|
||||||
"client_id": "135166769591-nopcgmo0fkqfqfs9qe783a137mtmcrt2.apps.googleusercontent.com",
|
"client_id": "135166769591-nopcgmo0fkqfqfs9qe783a137mtmcrt2.apps.googleusercontent.com",
|
||||||
"[SECRET-REMOVED]",
|
"secret": "[SECRET-REMOVED]",
|
||||||
"key": "",
|
"key": "",
|
||||||
},
|
},
|
||||||
"SCOPE": [
|
"SCOPE": [
|
||||||
@@ -192,7 +192,7 @@ SOCIALACCOUNT_PROVIDERS = {
|
|||||||
"discord": {
|
"discord": {
|
||||||
"APP": {
|
"APP": {
|
||||||
"client_id": "1299112802274902047",
|
"client_id": "1299112802274902047",
|
||||||
"[SECRET-REMOVED]",
|
"secret": "[SECRET-REMOVED]",
|
||||||
"key": "",
|
"key": "",
|
||||||
},
|
},
|
||||||
"SCOPE": ["identify", "email"],
|
"SCOPE": ["identify", "email"],
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ urlpatterns = [
|
|||||||
path("history/", include("history.urls", namespace="history")),
|
path("history/", include("history.urls", namespace="history")),
|
||||||
path(
|
path(
|
||||||
"env-settings/",
|
"env-settings/",
|
||||||
views***REMOVED***ironment_and_settings_view,
|
views.environment_and_settings_view,
|
||||||
name="environment_and_settings",
|
name="environment_and_settings",
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ class SearchView(TemplateView):
|
|||||||
|
|
||||||
def environment_and_settings_view(request):
|
def environment_and_settings_view(request):
|
||||||
# Get all environment variables
|
# Get all environment variables
|
||||||
env_vars = dict(os***REMOVED***iron)
|
env_vars = dict(os.environ)
|
||||||
|
|
||||||
# Get all Django settings as a dictionary
|
# Get all Django settings as a dictionary
|
||||||
settings_vars = {setting: getattr(settings, setting) for setting in dir(settings) if setting.isupper()}
|
settings_vars = {setting: getattr(settings, setting) for setting in dir(settings) if setting.isupper()}
|
||||||
|
|||||||
@@ -11,6 +11,6 @@ import os
|
|||||||
|
|
||||||
from django.core.wsgi import get_wsgi_application
|
from django.core.wsgi import get_wsgi_application
|
||||||
|
|
||||||
os***REMOVED***iron.setdefault("DJANGO_SETTINGS_MODULE", "thrillwiki.settings")
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "thrillwiki.settings")
|
||||||
|
|
||||||
application = get_wsgi_application()
|
application = get_wsgi_application()
|
||||||
|
|||||||
Reference in New Issue
Block a user