Compare commits

...

22 Commits

Author SHA1 Message Date
pacnpal
046257d06c Add database reset script and update package.json for db commands; refactor middleware for CORS support and error handling in parks page 2025-02-23 18:09:27 -05:00
pacnpal
c9ab1f40ed Add park detail API and detail page implementation with loading states and error handling 2025-02-23 17:57:52 -05:00
pacnpal
730b165f9c Initialize frontend project with Next.js, Tailwind CSS, and essential configurations 2025-02-23 16:01:56 -05:00
pacnpal
401449201c Fix search form duplication by updating event handler to submit the correct filter form and return JSON responses for park suggestions 2025-02-23 12:05:26 -05:00
pacnpal
1ca1362fee Implement park search suggestions with HTMX integration: replace legacy redirect with real-time suggestions and enhance UI for better user experience 2025-02-23 10:50:25 -05:00
pacnpal
02e4b82beb Allow unauthenticated access for autocomplete functionality 2025-02-22 15:17:49 -05:00
pacnpal
4339c5c5e0 Add autocomplete functionality for parks: implement BaseAutocomplete class and integrate with forms 2025-02-22 13:36:24 -05:00
pacnpal
5278ad39d0 Refactor imports and improve code organization: streamline import statements and enhance readability in parks/views.py 2025-02-21 20:37:03 -05:00
pacnpal
4d145ebabe Implement park search suggestions: add autocomplete functionality and improve search input handling 2025-02-21 20:36:12 -05:00
pacnpal
e4959b7a04 Improve address formatting in location widget: enhance address display logic and ensure fallback for missing fields 2025-02-21 20:20:00 -05:00
pacnpal
ef2437b7f4 2025-02-21 19:14:26 -05:00
pacnpal
3523274cbd Refactor error message handling: centralize required fields error message and improve park list template accessibility 2025-02-21 18:55:41 -05:00
pacnpal
d7951756dc Enhance park search functionality: update view mode handling and improve park list item layout 2025-02-21 18:52:01 -05:00
pacnpal
518fcbee22 Add custom development modes and guidelines for ThrillWiki project 2025-02-21 18:28:36 -05:00
pacnpal
41f1738cc1 Add migrations to alter primary key fields to BigAutoField for multiple models 2025-02-21 12:55:21 -05:00
pacnpal
645a74a4c3 Implement search functionality improvements: optimize database queries, enhance service layer, and update frontend interactions 2025-02-21 10:31:49 -05:00
pacnpal
8c85b2afd4 Update .clinerules: add guidelines for using UV with Django management commands 2025-02-19 11:13:21 -05:00
pacnpal
063398d220 Refactor development server startup instructions for clarity and conciseness 2025-02-19 09:59:39 -05:00
pacnpal
20ae4862e4 Add development server and package management guidelines to documentation 2025-02-19 09:56:23 -05:00
pacnpal
5541a5f02d Refactor park queryset logic: move base queryset to a dedicated module for improved organization and maintainability 2025-02-19 09:30:17 -05:00
pacnpal
78f465b273 Analyze feasibility of migrating from Django to Laravel; recommend maintaining current implementation due to high risks and costs 2025-02-18 10:43:13 -05:00
pacnpal
0b51ee123a Add comprehensive system architecture and feature documentation for ThrillWiki 2025-02-18 10:08:46 -05:00
94 changed files with 14914 additions and 584 deletions

30
.clinerules Normal file
View File

@@ -0,0 +1,30 @@
# Project Startup Rules
## Development Server
IMPORTANT: Always follow these instructions exactly when starting the development server:
```bash
lsof -ti :8000 | xargs kill -9; find . -type d -name "__pycache__" -exec rm -r {} +; uv run manage.py tailwind runserver
```
Note: These steps must be executed in this exact order as a single command to ensure consistent behavior.
## Package Management
IMPORTANT: When a Python package is needed, only use UV to add it:
```bash
uv add <package>
```
Do not attempt to install packages using any other method.
## Django Management Commands
IMPORTANT: When running any Django manage.py commands (migrations, shell, etc.), always use UV:
```bash
uv run manage.py <command>
```
This applies to all management commands including but not limited to:
- Making migrations: `uv run manage.py makemigrations`
- Applying migrations: `uv run manage.py migrate`
- Creating superuser: `uv run manage.py createsuperuser`
- Starting shell: `uv run manage.py shell`
NEVER use `python manage.py` or `uv run python manage.py`. Always use `uv run manage.py` directly.

20
.roomodes Normal file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,93 @@
# Generated by Django 5.1.4 on 2025-02-21 17:55
import django.utils.timezone
import pgtrigger.compiler
import pgtrigger.migrations
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("accounts", "0001_initial"),
]
operations = [
pgtrigger.migrations.RemoveTrigger(
model_name="toplistitem",
name="insert_insert",
),
pgtrigger.migrations.RemoveTrigger(
model_name="toplistitem",
name="update_update",
),
migrations.AddField(
model_name="toplistitem",
name="created_at",
field=models.DateTimeField(
auto_now_add=True, default=django.utils.timezone.now
),
preserve_default=False,
),
migrations.AddField(
model_name="toplistitem",
name="updated_at",
field=models.DateTimeField(auto_now=True),
),
migrations.AddField(
model_name="toplistitemevent",
name="created_at",
field=models.DateTimeField(
auto_now_add=True, default=django.utils.timezone.now
),
preserve_default=False,
),
migrations.AddField(
model_name="toplistitemevent",
name="updated_at",
field=models.DateTimeField(auto_now=True),
),
migrations.AlterField(
model_name="toplist",
name="id",
field=models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
migrations.AlterField(
model_name="toplistitem",
name="id",
field=models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
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="[AWS-SECRET-REMOVED]",
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="[AWS-SECRET-REMOVED]",
operation="UPDATE",
pgid="pgtrigger_update_update_2b6e3",
table="accounts_toplistitem",
when="AFTER",
),
),
),
]

View File

@@ -0,0 +1,27 @@
# Generated by Django 5.1.4 on 2025-02-21 17:55
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("companies", "0001_initial"),
]
operations = [
migrations.AlterField(
model_name="company",
name="id",
field=models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
migrations.AlterField(
model_name="manufacturer",
name="id",
field=models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
]

39
core/forms.py Normal file
View File

@@ -0,0 +1,39 @@
"""Core forms and form components."""
from django.conf import settings
from django.core.exceptions import PermissionDenied
from django.utils.translation import gettext_lazy as _
from autocomplete import Autocomplete
class BaseAutocomplete(Autocomplete):
"""Base autocomplete class for consistent autocomplete behavior across the project.
This class extends django-htmx-autocomplete's base Autocomplete class to provide:
- Project-wide defaults for autocomplete behavior
- Translation strings
- Authentication enforcement
- Sensible search configuration
"""
# Search configuration
minimum_search_length = 2 # More responsive than default 3
max_results = 10 # Reasonable limit for performance
# UI text configuration using gettext for i18n
no_result_text = _("No matches found")
narrow_search_text = _("Showing %(page_size)s of %(total)s matches. Please refine your search.")
type_at_least_n_characters = _("Type at least %(n)s characters...")
# Project-wide component settings
placeholder = _("Search...")
@staticmethod
def auth_check(request):
"""Enforce authentication by default.
This can be overridden in subclasses if public access is needed.
Configure AUTOCOMPLETE_BLOCK_UNAUTHENTICATED in settings to disable.
"""
block_unauth = getattr(settings, 'AUTOCOMPLETE_BLOCK_UNAUTHENTICATED', True)
if block_unauth and not request.user.is_authenticated:
raise PermissionDenied(_("Authentication required"))

View File

@@ -0,0 +1,20 @@
# Generated by Django 5.1.4 on 2025-02-21 17:55
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("designers", "0001_initial"),
]
operations = [
migrations.AlterField(
model_name="designer",
name="id",
field=models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
]

View File

@@ -0,0 +1,20 @@
# Generated by Django 5.1.4 on 2025-02-21 17:55
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("email_service", "0001_initial"),
]
operations = [
migrations.AlterField(
model_name="emailconfiguration",
name="id",
field=models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
]

41
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
***REMOVED***
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
***REMOVED****
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

36
frontend/README.md Normal file
View File

@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

View File

@@ -0,0 +1,16 @@
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
];
export default eslintConfig;

7
frontend/next.config.ts Normal file
View File

@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;

6898
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

34
frontend/package.json Normal file
View File

@@ -0,0 +1,34 @@
{
"name": "frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"db:reset": "ts-node src/scripts/db-reset.ts",
"db:seed": "ts-node prisma/seed.ts",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate deploy"
},
"dependencies": {
"@prisma/client": "^5.8.0",
"next": "14.1.0",
"react": "^18",
"react-dom": "^18"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"autoprefixer": "^10.0.1",
"eslint": "^8",
"eslint-config-next": "14.1.0",
"postcss": "^8",
"prisma": "^5.8.0",
"tailwindcss": "^3.3.0",
"ts-node": "^10.9.2",
"typescript": "^5"
}
}

View File

@@ -0,0 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
},
};
export default config;

View File

@@ -0,0 +1,116 @@
-- CreateExtension
CREATE EXTENSION IF NOT EXISTS "postgis";
-- CreateEnum
CREATE TYPE "ParkStatus" AS ENUM ('OPERATING', 'CLOSED_TEMP', 'CLOSED_PERM', 'UNDER_CONSTRUCTION', 'DEMOLISHED', 'RELOCATED');
-- CreateTable
CREATE TABLE "User" (
"id" SERIAL PRIMARY KEY,
"email" TEXT NOT NULL,
"username" TEXT NOT NULL,
"password" TEXT,
"dateJoined" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"isStaff" BOOLEAN NOT NULL DEFAULT false,
"isSuperuser" BOOLEAN NOT NULL DEFAULT false,
"lastLogin" TIMESTAMP(3)
);
-- CreateTable
CREATE TABLE "Park" (
"id" SERIAL PRIMARY KEY,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"description" TEXT,
"status" "ParkStatus" NOT NULL DEFAULT 'OPERATING',
"location" JSONB,
"opening_date" DATE,
"closing_date" DATE,
"operating_season" TEXT,
"size_acres" DECIMAL(10,2),
"website" TEXT,
"average_rating" DECIMAL(3,2),
"ride_count" INTEGER,
"coaster_count" INTEGER,
"creatorId" INTEGER,
"ownerId" INTEGER,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL
);
-- CreateTable
CREATE TABLE "ParkArea" (
"id" SERIAL PRIMARY KEY,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"description" TEXT,
"opening_date" DATE,
"closing_date" DATE,
"parkId" INTEGER NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL
);
-- CreateTable
CREATE TABLE "Company" (
"id" SERIAL PRIMARY KEY,
"name" TEXT NOT NULL,
"website" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL
);
-- CreateTable
CREATE TABLE "Review" (
"id" SERIAL PRIMARY KEY,
"content" TEXT NOT NULL,
"rating" INTEGER NOT NULL,
"parkId" INTEGER NOT NULL,
"userId" INTEGER NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL
);
-- CreateTable
CREATE TABLE "Photo" (
"id" SERIAL PRIMARY KEY,
"url" TEXT NOT NULL,
"caption" TEXT,
"parkId" INTEGER,
"reviewId" INTEGER,
"userId" INTEGER NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL
);
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
CREATE UNIQUE INDEX "Park_slug_key" ON "Park"("slug");
CREATE UNIQUE INDEX "ParkArea_parkId_slug_key" ON "ParkArea"("parkId", "slug");
-- AddForeignKey
ALTER TABLE "Park" ADD CONSTRAINT "Park_creatorId_fkey" FOREIGN KEY ("creatorId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "Park" ADD CONSTRAINT "Park_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "Company"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ParkArea" ADD CONSTRAINT "ParkArea_parkId_fkey" FOREIGN KEY ("parkId") REFERENCES "Park"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Review" ADD CONSTRAINT "Review_parkId_fkey" FOREIGN KEY ("parkId") REFERENCES "Park"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "Review" ADD CONSTRAINT "Review_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Photo" ADD CONSTRAINT "Photo_parkId_fkey" FOREIGN KEY ("parkId") REFERENCES "Park"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "Photo" ADD CONSTRAINT "Photo_reviewId_fkey" FOREIGN KEY ("reviewId") REFERENCES "Review"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "Photo" ADD CONSTRAINT "Photo_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- CreateIndex
CREATE INDEX "Park_slug_idx" ON "Park"("slug");
CREATE INDEX "ParkArea_slug_idx" ON "ParkArea"("slug");
CREATE INDEX "Review_parkId_idx" ON "Review"("parkId");
CREATE INDEX "Review_userId_idx" ON "Review"("userId");
CREATE INDEX "Photo_parkId_idx" ON "Photo"("parkId");
CREATE INDEX "Photo_reviewId_idx" ON "Photo"("reviewId");
CREATE INDEX "Photo_userId_idx" ON "Photo"("userId");

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

View File

@@ -0,0 +1,137 @@
// This is your Prisma schema file
generator client {
provider = "prisma-client-js"
previewFeatures = ["postgresqlExtensions"]
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
extensions = [postgis]
}
// Core user model
model User {
id Int @id @default(autoincrement())
email String @unique
username String @unique
password String?
dateJoined DateTime @default(now())
isActive Boolean @default(true)
isStaff Boolean @default(false)
isSuperuser Boolean @default(false)
lastLogin DateTime?
createdParks Park[] @relation("ParkCreator")
reviews Review[]
photos Photo[]
}
// Park model
model Park {
id Int @id @default(autoincrement())
name String
slug String @unique
description String? @db.Text
status ParkStatus @default(OPERATING)
location Json? // Store PostGIS point as JSON for now
// Details
opening_date DateTime? @db.Date
closing_date DateTime? @db.Date
operating_season String?
size_acres Decimal? @db.Decimal(10, 2)
website String?
// Statistics
average_rating Decimal? @db.Decimal(3, 2)
ride_count Int?
coaster_count Int?
// Relationships
creator User? @relation("ParkCreator", fields: [creatorId], references: [id])
creatorId Int?
owner Company? @relation(fields: [ownerId], references: [id])
ownerId Int?
areas ParkArea[]
reviews Review[]
photos Photo[]
// Metadata
created_at DateTime @default(now())
updated_at DateTime @updatedAt
@@index([slug])
}
// Park Area model
model ParkArea {
id Int @id @default(autoincrement())
name String
slug String
description String? @db.Text
opening_date DateTime? @db.Date
closing_date DateTime? @db.Date
park Park @relation(fields: [parkId], references: [id], onDelete: Cascade)
parkId Int
created_at DateTime @default(now())
updated_at DateTime @updatedAt
@@unique([parkId, slug])
@@index([slug])
}
// Company model (for park owners)
model Company {
id Int @id @default(autoincrement())
name String
website String?
parks Park[]
created_at DateTime @default(now())
updated_at DateTime @updatedAt
}
// Review model
model Review {
id Int @id @default(autoincrement())
content String @db.Text
rating Int
park Park @relation(fields: [parkId], references: [id])
parkId Int
user User @relation(fields: [userId], references: [id])
userId Int
photos Photo[]
created_at DateTime @default(now())
updated_at DateTime @updatedAt
@@index([parkId])
@@index([userId])
}
// Photo model
model Photo {
id Int @id @default(autoincrement())
url String
caption String?
park Park? @relation(fields: [parkId], references: [id])
parkId Int?
review Review? @relation(fields: [reviewId], references: [id])
reviewId Int?
user User @relation(fields: [userId], references: [id])
userId Int
created_at DateTime @default(now())
updated_at DateTime @updatedAt
@@index([parkId])
@@index([reviewId])
@@index([userId])
}
enum ParkStatus {
OPERATING
CLOSED_TEMP
CLOSED_PERM
UNDER_CONSTRUCTION
DEMOLISHED
RELOCATED
}

148
frontend/prisma/seed.ts Normal file
View File

@@ -0,0 +1,148 @@
import { PrismaClient, ParkStatus } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
// Create test users
const user1 = await prisma.user.create({
data: {
email: 'admin@thrillwiki.com',
username: 'admin',
password: 'hashed_password_here',
isActive: true,
isStaff: true,
isSuperuser: true,
},
});
const user2 = await prisma.user.create({
data: {
email: 'testuser@thrillwiki.com',
username: 'testuser',
password: 'hashed_password_here',
isActive: true,
},
});
// Create test companies
const company1 = await prisma.company.create({
data: {
name: 'Universal Parks & Resorts',
website: 'https://www.universalparks.com',
},
});
const company2 = await prisma.company.create({
data: {
name: 'Cedar Fair Entertainment Company',
website: 'https://www.cedarfair.com',
},
});
// Create test parks
const park1 = await prisma.park.create({
data: {
name: 'Universal Studios Florida',
slug: 'universal-studios-florida',
description: 'Movie and TV-based theme park in Orlando, Florida',
status: ParkStatus.OPERATING,
location: {
latitude: 28.4742,
longitude: -81.4678,
},
opening_date: new Date('1990-06-07'),
operating_season: 'Year-round',
size_acres: 108,
website: 'https://www.universalorlando.com',
average_rating: 4.5,
ride_count: 25,
coaster_count: 2,
creatorId: user1.id,
ownerId: company1.id,
areas: {
create: [
{
name: 'Production Central',
slug: 'production-central',
description: 'The main entrance area of the park',
opening_date: new Date('1990-06-07'),
},
{
name: 'New York',
slug: 'new-york',
description: 'Themed after New York City streets',
opening_date: new Date('1990-06-07'),
},
],
},
},
});
const park2 = await prisma.park.create({
data: {
name: 'Cedar Point',
slug: 'cedar-point',
description: 'The Roller Coaster Capital of the World',
status: ParkStatus.OPERATING,
location: {
latitude: 41.4822,
longitude: -82.6837,
},
opening_date: new Date('1870-05-01'),
operating_season: 'May through October',
size_acres: 364,
website: 'https://www.cedarpoint.com',
average_rating: 4.8,
ride_count: 71,
coaster_count: 17,
creatorId: user1.id,
ownerId: company2.id,
areas: {
create: [
{
name: 'FrontierTown',
slug: 'frontiertown',
description: 'Western-themed area of the park',
opening_date: new Date('1968-05-01'),
},
{
name: 'Millennium Island',
slug: 'millennium-island',
description: 'Home of the Millennium Force',
opening_date: new Date('2000-05-13'),
},
],
},
},
});
// Create test reviews
await prisma.review.create({
data: {
content: 'Amazing theme park with great attention to detail!',
rating: 5,
parkId: park1.id,
userId: user2.id,
},
});
await prisma.review.create({
data: {
content: 'Best roller coasters in the world!',
rating: 5,
parkId: park2.id,
userId: user2.id,
},
});
console.log('Seed data created successfully');
}
main()
.catch((e) => {
console.error('Error creating seed data:', e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

1
frontend/public/file.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

1
frontend/public/next.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

View File

@@ -0,0 +1,103 @@
import { NextRequest, NextResponse } from 'next/server';
import prisma from '@/lib/prisma';
import { ParkDetailResponse } from '@/types/api';
export async function GET(
request: NextRequest,
{ params }: { params: { slug: string } }
): Promise<NextResponse<ParkDetailResponse>> {
try {
// Ensure database connection is initialized
if (!prisma) {
throw new Error('Database connection not initialized');
}
// Find park by slug with all relationships
const park = await prisma.park.findUnique({
where: { slug: params.slug },
include: {
creator: {
select: {
id: true,
username: true,
email: true,
},
},
owner: true,
areas: true,
reviews: {
include: {
user: {
select: {
id: true,
username: true,
},
},
},
},
photos: {
include: {
user: {
select: {
id: true,
username: true,
},
},
},
},
},
});
// Return 404 if park not found
if (!park) {
return NextResponse.json(
{
success: false,
error: 'Park not found',
},
{ status: 404 }
);
}
// Format dates consistently with list endpoint
const formattedPark = {
...park,
opening_date: park.opening_date?.toISOString().split('T')[0],
closing_date: park.closing_date?.toISOString().split('T')[0],
created_at: park.created_at.toISOString(),
updated_at: park.updated_at.toISOString(),
// Format nested dates
areas: park.areas.map(area => ({
...area,
opening_date: area.opening_date?.toISOString().split('T')[0],
closing_date: area.closing_date?.toISOString().split('T')[0],
created_at: area.created_at.toISOString(),
updated_at: area.updated_at.toISOString(),
})),
reviews: park.reviews.map(review => ({
...review,
created_at: review.created_at.toISOString(),
updated_at: review.updated_at.toISOString(),
})),
photos: park.photos.map(photo => ({
...photo,
created_at: photo.created_at.toISOString(),
updated_at: photo.updated_at.toISOString(),
})),
};
return NextResponse.json({
success: true,
data: formattedPark,
});
} catch (error) {
console.error('Error fetching park:', error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to fetch park',
},
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,88 @@
import { NextResponse } from 'next/server';
import { Prisma } from '@prisma/client';
import prisma from '@/lib/prisma';
export async function GET(request: Request) {
try {
// Test raw query first
try {
console.log('Testing database connection...');
const rawResult = await prisma.$queryRaw`SELECT tablename FROM pg_catalog.pg_tables WHERE schemaname = 'public'`;
console.log('Available tables:', rawResult);
} catch (connectionError) {
console.error('Raw query test failed:', connectionError);
throw new Error('Database connection test failed');
}
// Basic query with explicit types
try {
const queryResult = await prisma.$transaction(async (tx) => {
// Count total parks
const totalCount = await tx.park.count();
console.log('Total parks count:', totalCount);
// Fetch parks with minimal fields
const parks = await tx.park.findMany({
take: 10,
select: {
id: true,
name: true,
slug: true,
status: true,
owner: {
select: {
id: true,
name: true
}
}
},
orderBy: {
name: 'asc'
}
} satisfies Prisma.ParkFindManyArgs);
return { totalCount, parks };
});
return NextResponse.json({
success: true,
data: queryResult.parks,
meta: {
total: queryResult.totalCount
}
});
} catch (queryError) {
if (queryError instanceof Prisma.PrismaClientKnownRequestError) {
console.error('Known Prisma error:', {
code: queryError.code,
meta: queryError.meta,
message: queryError.message
});
throw new Error(`Database query failed: ${queryError.code}`);
}
throw queryError;
}
} catch (error) {
console.error('Error in /api/parks:', {
name: error instanceof Error ? error.name : 'Unknown',
message: error instanceof Error ? error.message : 'Unknown error',
stack: error instanceof Error ? error.stack : undefined
});
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to fetch parks'
},
{
status: 500,
headers: {
'Cache-Control': 'no-store, must-revalidate',
'Content-Type': 'application/json'
}
}
);
}
}

View File

@@ -0,0 +1,50 @@
import { NextResponse } from 'next/server';
import prisma from '@/lib/prisma';
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const search = searchParams.get('search')?.trim();
if (!search) {
return NextResponse.json({
success: true,
data: []
});
}
const parks = await prisma.park.findMany({
where: {
OR: [
{ name: { contains: search, mode: 'insensitive' } },
{ owner: { name: { contains: search, mode: 'insensitive' } } }
]
},
select: {
id: true,
name: true,
slug: true,
status: true,
owner: {
select: {
name: true,
slug: true
}
}
},
take: 8 // Limit quick search results like Django
});
return NextResponse.json({
success: true,
data: parks
});
} catch (error) {
console.error('Error in /api/parks/suggest:', error);
return NextResponse.json({
success: false,
error: 'Failed to fetch park suggestions'
}, { status: 500 });
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -0,0 +1,21 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--background: #ffffff;
--foreground: #171717;
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
color: var(--foreground);
background: var(--background);
font-family: Arial, Helvetica, sans-serif;
}

View File

@@ -0,0 +1,62 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "ThrillWiki - Theme Park Information & Reviews",
description: "Discover theme parks, share experiences, and read reviews from park enthusiasts.",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body className={inter.className}>
<div className="min-h-screen bg-white">
<header className="bg-indigo-600">
<nav className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8" aria-label="Top">
<div className="flex w-full items-center justify-between border-b border-indigo-500 py-6">
<div className="flex items-center">
<a href="/" className="text-white text-2xl font-bold">
ThrillWiki
</a>
</div>
<div className="ml-10 space-x-4">
<a
href="/parks"
className="inline-block rounded-md border border-transparent bg-indigo-500 py-2 px-4 text-base font-medium text-white hover:bg-opacity-75"
>
Parks
</a>
<a
href="/login"
className="inline-block rounded-md border border-transparent bg-white py-2 px-4 text-base font-medium text-indigo-600 hover:bg-indigo-50"
>
Login
</a>
</div>
</div>
</nav>
</header>
{children}
<footer className="bg-white">
<div className="mx-auto max-w-7xl px-6 py-12 md:flex md:items-center md:justify-between lg:px-8">
<div className="mt-8 md:order-1 md:mt-0">
<p className="text-center text-xs leading-5 text-gray-500">
&copy; {new Date().getFullYear()} ThrillWiki. All rights reserved.
</p>
</div>
</div>
</footer>
</div>
</body>
</html>
);
}

84
frontend/src/app/page.tsx Normal file
View File

@@ -0,0 +1,84 @@
'use client';
import { useEffect, useState } from 'react';
import type { Park } from '@/types/api';
export default function Home() {
const [parks, setParks] = useState<Park[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
async function fetchParks() {
try {
const response = await fetch('/api/parks');
const data = await response.json();
if (!data.success) {
throw new Error(data.error || 'Failed to fetch parks');
}
setParks(data.data || []);
} catch (err) {
setError(err instanceof Error ? err.message : 'An error occurred');
} finally {
setLoading(false);
}
}
fetchParks();
}, []);
if (loading) {
return (
<main className="min-h-screen p-8">
<div className="text-center">
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-current border-r-transparent align-[-0.125em] motion-reduce:animate-[spin_1.5s_linear_infinite]" />
<p className="mt-4 text-gray-600">Loading parks...</p>
</div>
</main>
);
}
if (error) {
return (
<main className="min-h-screen p-8">
<div className="rounded-lg bg-red-50 p-4 border border-red-200">
<h2 className="text-red-800 font-semibold">Error</h2>
<p className="text-red-600 mt-2">{error}</p>
</div>
</main>
);
}
return (
<main className="min-h-screen p-8">
<h1 className="text-3xl font-bold mb-8">ThrillWiki Parks</h1>
{parks.length === 0 ? (
<p className="text-gray-600">No parks found</p>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{parks.map((park) => (
<div
key={park.id}
className="rounded-lg border border-gray-200 p-6 hover:shadow-lg transition-shadow"
>
<h2 className="text-xl font-semibold mb-2">{park.name}</h2>
{park.description && (
<p className="text-gray-600 mb-4">
{park.description.length > 150
? `${park.description.slice(0, 150)}...`
: park.description}
</p>
)}
<div className="text-sm text-gray-500">
Added: {new Date(park.createdAt).toLocaleDateString()}
</div>
</div>
))}
</div>
)}
</main>
);
}

View File

@@ -0,0 +1,99 @@
export default function ParkDetailLoading() {
return (
<main className="container mx-auto px-4 py-8 animate-pulse">
<article className="bg-white rounded-lg shadow-lg p-6">
{/* Header skeleton */}
<header className="mb-8">
<div className="h-10 w-3/4 bg-gray-200 rounded mb-4"></div>
<div className="flex items-center gap-4">
<div className="h-6 w-24 bg-gray-200 rounded-full"></div>
<div className="h-6 w-32 bg-gray-200 rounded"></div>
</div>
</header>
{/* Description skeleton */}
<section className="mb-8">
<div className="space-y-2">
<div className="h-4 bg-gray-200 rounded w-full"></div>
<div className="h-4 bg-gray-200 rounded w-5/6"></div>
<div className="h-4 bg-gray-200 rounded w-4/6"></div>
</div>
</section>
{/* Details skeleton */}
<section className="grid grid-cols-1 md:grid-cols-2 gap-8 mb-8">
<div>
<div className="h-8 w-32 bg-gray-200 rounded mb-4"></div>
<div className="space-y-4">
{[...Array(4)].map((_, i) => (
<div key={i} className="grid grid-cols-2 gap-4">
<div className="h-6 bg-gray-200 rounded"></div>
<div className="h-6 bg-gray-200 rounded"></div>
</div>
))}
</div>
</div>
<div>
<div className="h-8 w-32 bg-gray-200 rounded mb-4"></div>
<div className="bg-gray-100 p-4 rounded-lg">
<div className="space-y-2">
<div className="h-6 bg-gray-200 rounded"></div>
<div className="h-6 bg-gray-200 rounded"></div>
</div>
</div>
</div>
</section>
{/* Areas skeleton */}
<section className="mb-8">
<div className="h-8 w-32 bg-gray-200 rounded mb-4"></div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{[...Array(3)].map((_, i) => (
<div key={i} className="bg-gray-50 p-4 rounded-lg">
<div className="h-6 bg-gray-200 rounded mb-2"></div>
<div className="h-4 bg-gray-200 rounded w-3/4"></div>
</div>
))}
</div>
</section>
{/* Reviews skeleton */}
<section className="mb-8">
<div className="h-8 w-32 bg-gray-200 rounded mb-4"></div>
<div className="space-y-4">
{[...Array(2)].map((_, i) => (
<div key={i} className="bg-gray-50 p-4 rounded-lg">
<div className="flex justify-between items-start mb-2">
<div className="space-y-2">
<div className="h-6 bg-gray-200 rounded w-32"></div>
<div className="h-4 bg-gray-200 rounded w-24"></div>
</div>
<div className="h-6 w-24 bg-gray-200 rounded"></div>
</div>
<div className="space-y-2 mt-4">
<div className="h-4 bg-gray-200 rounded w-full"></div>
<div className="h-4 bg-gray-200 rounded w-5/6"></div>
</div>
<div className="mt-2 flex gap-2">
{[...Array(2)].map((_, j) => (
<div key={j} className="w-24 h-24 bg-gray-200 rounded"></div>
))}
</div>
</div>
))}
</div>
</section>
{/* Photos skeleton */}
<section>
<div className="h-8 w-32 bg-gray-200 rounded mb-4"></div>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{[...Array(8)].map((_, i) => (
<div key={i} className="aspect-square bg-gray-200 rounded-lg"></div>
))}
</div>
</section>
</article>
</main>
);
}

View File

@@ -0,0 +1,194 @@
import { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { Park, ParkDetailResponse } from '@/types/api';
// Dynamically generate metadata for park pages
export async function generateMetadata({ params }: { params: { slug: string } }): Promise<Metadata> {
try {
const park = await fetchParkData(params.slug);
return {
title: `${park.name} | ThrillWiki`,
description: park.description || `Details about ${park.name}`,
};
} catch (error) {
return {
title: 'Park Not Found | ThrillWiki',
description: 'The requested park could not be found.',
};
}
}
// Fetch park data from API
async function fetchParkData(slug: string): Promise<Park> {
const response = await fetch(`${process***REMOVED***.NEXT_PUBLIC_API_URL}/api/parks/${slug}`, {
next: { revalidate: 60 }, // Cache for 1 minute
});
if (!response.ok) {
if (response.status === 404) {
notFound();
}
throw new Error('Failed to fetch park data');
}
const { data }: ParkDetailResponse = await response.json();
return data;
}
// Park detail page component
export default async function ParkDetailPage({ params }: { params: { slug: string } }) {
const park = await fetchParkData(params.slug);
return (
<main className="container mx-auto px-4 py-8">
<article className="bg-white rounded-lg shadow-lg p-6">
{/* Park header */}
<header className="mb-8">
<h1 className="text-4xl font-bold mb-4">{park.name}</h1>
<div className="flex items-center gap-4 text-gray-600">
<span className={`px-3 py-1 rounded-full text-sm ${
park.status === 'OPERATING' ? 'bg-green-100 text-green-800' :
park.status === 'CLOSED_TEMP' ? 'bg-yellow-100 text-yellow-800' :
park.status === 'UNDER_CONSTRUCTION' ? 'bg-blue-100 text-blue-800' :
'bg-red-100 text-red-800'
}`}>
{park.status.replace('_', ' ')}
</span>
{park.opening_date && (
<span>Opened: {park.opening_date}</span>
)}
</div>
</header>
{/* Park description */}
{park.description && (
<section className="mb-8">
<p className="text-gray-700 leading-relaxed">{park.description}</p>
</section>
)}
{/* Park details */}
<section className="grid grid-cols-1 md:grid-cols-2 gap-8 mb-8">
<div>
<h2 className="text-2xl font-semibold mb-4">Details</h2>
<dl className="grid grid-cols-2 gap-4">
{park.size_acres && (
<>
<dt className="font-medium text-gray-600">Size</dt>
<dd>{park.size_acres} acres</dd>
</>
)}
{park.operating_season && (
<>
<dt className="font-medium text-gray-600">Season</dt>
<dd>{park.operating_season}</dd>
</>
)}
{park.ride_count && (
<>
<dt className="font-medium text-gray-600">Total Rides</dt>
<dd>{park.ride_count}</dd>
</>
)}
{park.coaster_count && (
<>
<dt className="font-medium text-gray-600">Roller Coasters</dt>
<dd>{park.coaster_count}</dd>
</>
)}
{park.average_rating && (
<>
<dt className="font-medium text-gray-600">Average Rating</dt>
<dd>{park.average_rating.toFixed(1)} / 5.0</dd>
</>
)}
</dl>
</div>
{/* Location */}
{park.location && (
<div>
<h2 className="text-2xl font-semibold mb-4">Location</h2>
<div className="bg-gray-100 p-4 rounded-lg">
<p>Latitude: {park.location.latitude}</p>
<p>Longitude: {park.location.longitude}</p>
</div>
</div>
)}
</section>
{/* Areas */}
{park.areas.length > 0 && (
<section className="mb-8">
<h2 className="text-2xl font-semibold mb-4">Areas</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{park.areas.map(area => (
<div key={area.id} className="bg-gray-50 p-4 rounded-lg">
<h3 className="font-semibold mb-2">{area.name}</h3>
{area.description && (
<p className="text-sm text-gray-600">{area.description}</p>
)}
</div>
))}
</div>
</section>
)}
{/* Reviews */}
{park.reviews.length > 0 && (
<section className="mb-8">
<h2 className="text-2xl font-semibold mb-4">Reviews</h2>
<div className="space-y-4">
{park.reviews.map(review => (
<div key={review.id} className="bg-gray-50 p-4 rounded-lg">
<div className="flex justify-between items-start mb-2">
<div>
<span className="font-medium">{review.user.username}</span>
<span className="text-gray-600 text-sm ml-2">
{new Date(review.created_at).toLocaleDateString()}
</span>
</div>
<div className="text-yellow-500">{
'★'.repeat(review.rating) + '☆'.repeat(5 - review.rating)
}</div>
</div>
<p className="text-gray-700">{review.content}</p>
{review.photos.length > 0 && (
<div className="mt-2 flex gap-2 flex-wrap">
{review.photos.map(photo => (
<img
key={photo.id}
src={photo.url}
alt={photo.caption || `Photo by ${photo.user.username}`}
className="w-24 h-24 object-cover rounded"
/>
))}
</div>
)}
</div>
))}
</div>
</section>
)}
{/* Photos */}
{park.photos.length > 0 && (
<section>
<h2 className="text-2xl font-semibold mb-4">Photos</h2>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{park.photos.map(photo => (
<div key={photo.id} className="aspect-square relative">
<img
src={photo.url}
alt={photo.caption || `Photo of ${park.name}`}
className="absolute inset-0 w-full h-full object-cover rounded-lg"
/>
</div>
))}
</div>
</section>
)}
</article>
</main>
);
}

View File

@@ -0,0 +1,150 @@
'use client';
import { useEffect, useState } from 'react';
import type { Park, ParkFilterValues, Company } from '@/types/api';
import { ParkSearch } from '@/components/parks/ParkSearch';
import { ViewToggle } from '@/components/parks/ViewToggle';
import { ParkList } from '@/components/parks/ParkList';
import { ParkFilters } from '@/components/parks/ParkFilters';
export default function ParksPage() {
const [parks, setParks] = useState<Park[]>([]);
const [companies, setCompanies] = useState<Company[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
const [searchQuery, setSearchQuery] = useState('');
const [filters, setFilters] = useState<ParkFilterValues>({});
// Fetch companies for filter dropdown
useEffect(() => {
async function fetchCompanies() {
try {
const response = await fetch('/api/companies');
const data = await response.json();
if (!data.success) {
throw new Error(data.error || 'Failed to fetch companies');
}
setCompanies(data.data || []);
} catch (err) {
console.error('Failed to fetch companies:', err);
// Don't set error state for companies - just show empty list
setCompanies([]);
}
}
fetchCompanies();
}, []);
// Fetch parks with filters
useEffect(() => {
async function fetchParks() {
try {
setLoading(true);
setError(null);
const queryParams = new URLSearchParams();
// Only add defined parameters
if (searchQuery?.trim()) queryParams.set('search', searchQuery.trim());
if (filters.status) queryParams.set('status', filters.status);
if (filters.ownerId) queryParams.set('ownerId', filters.ownerId);
if (filters.hasOwner !== undefined) queryParams.set('hasOwner', filters.hasOwner.toString());
if (filters.minRides) queryParams.set('minRides', filters.minRides.toString());
if (filters.minCoasters) queryParams.set('minCoasters', filters.minCoasters.toString());
if (filters.minSize) queryParams.set('minSize', filters.minSize.toString());
if (filters.openingDateStart) queryParams.set('openingDateStart', filters.openingDateStart);
if (filters.openingDateEnd) queryParams.set('openingDateEnd', filters.openingDateEnd);
const response = await fetch(`/api/parks?${queryParams}`);
const data = await response.json();
if (!data.success) {
throw new Error(data.error || 'Failed to fetch parks');
}
setParks(data.data || []);
setError(null);
} catch (err) {
console.error('Error fetching parks:', err);
setError(err instanceof Error ? err.message : 'An error occurred while fetching parks');
setParks([]);
} finally {
setLoading(false);
}
}
fetchParks();
}, [searchQuery, filters]);
const handleSearch = (query: string) => {
setSearchQuery(query);
};
const handleFiltersChange = (newFilters: ParkFilterValues) => {
setFilters(newFilters);
};
if (loading) {
return (
<div className="min-h-screen p-8">
<div className="text-center">
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-current border-r-transparent align-[-0.125em] motion-reduce:animate-[spin_1.5s_linear_infinite]" />
<p className="mt-4 text-gray-600">Loading parks...</p>
</div>
</div>
);
}
if (error && !parks.length) {
return (
<div className="p-4" data-testid="park-list-error">
<div className="inline-flex items-center px-4 py-2 rounded-md bg-red-50 text-red-700">
<svg className="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd"/>
</svg>
{error}
</div>
</div>
);
}
return (
<div className="min-h-screen p-8">
<div className="max-w-7xl mx-auto">
<div className="flex justify-between items-center mb-6">
<div className="flex items-center space-x-4">
<h1 className="text-2xl font-bold text-gray-900">Parks</h1>
<ViewToggle currentView={viewMode} onViewChange={setViewMode} />
</div>
</div>
<div className="mb-6">
<ParkSearch onSearch={handleSearch} />
<ParkFilters onFiltersChange={handleFiltersChange} companies={companies} />
</div>
<div
id="park-results"
className="bg-white rounded-lg shadow overflow-hidden"
data-view-mode={viewMode}
>
<div className="transition-all duration-300 ease-in-out">
<ParkList
parks={parks}
viewMode={viewMode}
searchQuery={searchQuery}
/>
</div>
</div>
{error && parks.length > 0 && (
<div className="mt-4 p-4 bg-yellow-50 border border-yellow-200 rounded-lg text-yellow-800">
<p className="text-sm">
Some data might be incomplete or outdated: {error}
</p>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,81 @@
'use client';
import { Component, ErrorInfo, ReactNode } from 'react';
interface Props {
children: ReactNode;
fallback?: ReactNode;
}
interface State {
hasError: boolean;
error?: Error;
}
export class ErrorBoundary extends Component<Props, State> {
public state: State = {
hasError: false
};
public static getDerivedStateFromError(error: Error): State {
return {
hasError: true,
error
};
}
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('Uncaught error:', error, errorInfo);
}
public render() {
if (this.state.hasError) {
return this.props.fallback || (
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8">
<div className="rounded-md bg-red-50 p-4">
<div className="flex">
<div className="flex-shrink-0">
<svg
className="h-5 w-5 text-red-400"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
clipRule="evenodd"
/>
</svg>
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-red-800">
Something went wrong
</h3>
{this.state.error && (
<div className="mt-2 text-sm text-red-700">
<p>{this.state.error.message}</p>
</div>
)}
<div className="mt-4">
<button
type="button"
onClick={() => window.location.reload()}
className="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-red-700 bg-red-100 hover:bg-red-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"
>
Retry
</button>
</div>
</div>
</div>
</div>
</div>
</div>
);
}
return this.props.children;
}
}

View File

@@ -0,0 +1,55 @@
import Link from 'next/link';
import type { Park } from '@/types/api';
interface ParkCardProps {
park: Park;
}
function getStatusBadgeClass(status: string): string {
const statusClasses = {
OPERATING: 'bg-green-100 text-green-800',
CLOSED_TEMP: 'bg-yellow-100 text-yellow-800',
CLOSED_PERM: 'bg-red-100 text-red-800',
UNDER_CONSTRUCTION: 'bg-blue-100 text-blue-800',
DEMOLISHED: 'bg-gray-100 text-gray-800',
RELOCATED: 'bg-purple-100 text-purple-800'
};
return statusClasses[status as keyof typeof statusClasses] || 'bg-gray-100 text-gray-500';
}
export function ParkCard({ park }: ParkCardProps) {
const statusClass = getStatusBadgeClass(park.status);
const formattedStatus = park.status.replace(/_/g, ' ');
return (
<div className="overflow-hidden transition-transform transform bg-white rounded-lg shadow-lg dark:bg-gray-800 hover:-translate-y-1">
<div className="p-4">
<h2 className="mb-2 text-xl font-bold">
<Link
href={`/parks/${park.slug}`}
className="text-gray-900 hover:text-blue-600 dark:text-white dark:hover:text-blue-400"
>
{park.name}
</Link>
</h2>
<div className="flex flex-wrap gap-2">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${statusClass}`}>
{formattedStatus}
</span>
</div>
{park.owner && (
<div className="mt-4 text-sm">
<Link
href={`/companies/${park.owner.slug}`}
className="text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
>
{park.owner.name}
</Link>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,182 @@
'use client';
import { useState } from 'react';
import type { Company, ParkStatus } from '@/types/api';
const STATUS_OPTIONS = {
OPERATING: 'Operating',
CLOSED_TEMP: 'Temporarily Closed',
CLOSED_PERM: 'Permanently Closed',
UNDER_CONSTRUCTION: 'Under Construction',
DEMOLISHED: 'Demolished',
RELOCATED: 'Relocated'
} as const;
interface ParkFiltersProps {
onFiltersChange: (filters: ParkFilterValues) => void;
companies: Company[];
}
interface ParkFilterValues {
status?: ParkStatus;
ownerId?: string;
hasOwner?: boolean;
minRides?: number;
minCoasters?: number;
minSize?: number;
openingDateStart?: string;
openingDateEnd?: string;
}
export function ParkFilters({ onFiltersChange, companies }: ParkFiltersProps) {
const [filters, setFilters] = useState<ParkFilterValues>({});
const handleFilterChange = (field: keyof ParkFilterValues, value: any) => {
const newFilters = {
...filters,
[field]: value === '' ? undefined : value
};
setFilters(newFilters);
onFiltersChange(newFilters);
};
return (
<div className="bg-white shadow sm:rounded-lg">
<div className="px-4 py-5 sm:p-6">
<h3 className="text-lg font-medium leading-6 text-gray-900">Filters</h3>
<div className="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{/* Status Filter */}
<div>
<label htmlFor="status" className="block text-sm font-medium text-gray-700">
Operating Status
</label>
<select
id="status"
name="status"
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
value={filters.status || ''}
onChange={(e) => handleFilterChange('status', e.target.value)}
>
<option value="">Any status</option>
{Object.entries(STATUS_OPTIONS).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</div>
{/* Owner Filter */}
<div>
<label htmlFor="owner" className="block text-sm font-medium text-gray-700">
Operating Company
</label>
<select
id="owner"
name="owner"
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
value={filters.ownerId || ''}
onChange={(e) => handleFilterChange('ownerId', e.target.value)}
>
<option value="">Any company</option>
{companies.map((company) => (
<option key={company.id} value={company.id}>
{company.name}
</option>
))}
</select>
</div>
{/* Has Owner Filter */}
<div>
<label htmlFor="hasOwner" className="block text-sm font-medium text-gray-700">
Company Status
</label>
<select
id="hasOwner"
name="hasOwner"
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
value={filters.hasOwner === undefined ? '' : filters.hasOwner.toString()}
onChange={(e) => handleFilterChange('hasOwner', e.target.value === '' ? undefined : e.target.value === 'true')}
>
<option value="">Show all</option>
<option value="true">Has company</option>
<option value="false">No company</option>
</select>
</div>
{/* Min Rides Filter */}
<div>
<label htmlFor="minRides" className="block text-sm font-medium text-gray-700">
Minimum Rides
</label>
<input
type="number"
id="minRides"
name="minRides"
min="0"
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
value={filters.minRides || ''}
onChange={(e) => handleFilterChange('minRides', e.target.value ? parseInt(e.target.value, 10) : '')}
/>
</div>
{/* Min Coasters Filter */}
<div>
<label htmlFor="minCoasters" className="block text-sm font-medium text-gray-700">
Minimum Roller Coasters
</label>
<input
type="number"
id="minCoasters"
name="minCoasters"
min="0"
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
value={filters.minCoasters || ''}
onChange={(e) => handleFilterChange('minCoasters', e.target.value ? parseInt(e.target.value, 10) : '')}
/>
</div>
{/* Min Size Filter */}
<div>
<label htmlFor="minSize" className="block text-sm font-medium text-gray-700">
Minimum Size (acres)
</label>
<input
type="number"
id="minSize"
name="minSize"
min="0"
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
value={filters.minSize || ''}
onChange={(e) => handleFilterChange('minSize', e.target.value ? parseInt(e.target.value, 10) : '')}
/>
</div>
{/* Opening Date Range */}
<div className="sm:col-span-2 lg:col-span-3">
<label className="block text-sm font-medium text-gray-700">Opening Date Range</label>
<div className="mt-1 grid grid-cols-2 gap-4">
<input
type="date"
id="openingDateStart"
name="openingDateStart"
className="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
value={filters.openingDateStart || ''}
onChange={(e) => handleFilterChange('openingDateStart', e.target.value)}
/>
<input
type="date"
id="openingDateEnd"
name="openingDateEnd"
className="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
value={filters.openingDateEnd || ''}
onChange={(e) => handleFilterChange('openingDateEnd', e.target.value)}
/>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,41 @@
import type { Park } from '@/types/api';
import { ParkCard } from './ParkCard';
import { ParkListItem } from './ParkListItem';
interface ParkListProps {
parks: Park[];
viewMode: 'grid' | 'list';
searchQuery?: string;
}
export function ParkList({ parks, viewMode, searchQuery }: ParkListProps) {
if (parks.length === 0) {
return (
<div className="col-span-full p-4 text-sm text-gray-500 text-center" data-testid="no-parks-found">
{searchQuery ? (
<>No parks found matching "{searchQuery}". Try adjusting your search terms.</>
) : (
<>No parks found matching your criteria. Try adjusting your filters.</>
)}
</div>
);
}
if (viewMode === 'list') {
return (
<div className="divide-y divide-gray-200">
{parks.map((park) => (
<ParkListItem key={park.id} park={park} />
))}
</div>
);
}
return (
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{parks.map((park) => (
<ParkCard key={park.id} park={park} />
))}
</div>
);
}

View File

@@ -0,0 +1,70 @@
import Link from 'next/link';
import type { Park } from '@/types/api';
interface ParkListItemProps {
park: Park;
}
function getStatusBadgeClass(status: string): string {
const statusClasses = {
OPERATING: 'bg-green-100 text-green-800',
CLOSED_TEMP: 'bg-yellow-100 text-yellow-800',
CLOSED_PERM: 'bg-red-100 text-red-800',
UNDER_CONSTRUCTION: 'bg-blue-100 text-blue-800',
DEMOLISHED: 'bg-gray-100 text-gray-800',
RELOCATED: 'bg-purple-100 text-purple-800'
};
return statusClasses[status as keyof typeof statusClasses] || 'bg-gray-100 text-gray-500';
}
export function ParkListItem({ park }: ParkListItemProps) {
const statusClass = getStatusBadgeClass(park.status);
const formattedStatus = park.status.replace(/_/g, ' ');
return (
<div className="flex flex-col sm:flex-row sm:items-center justify-between p-4 border-b border-gray-200 last:border-b-0">
<div className="flex-1">
<div className="flex items-start justify-between">
<h2 className="text-lg font-semibold mb-1">
<Link
href={`/parks/${park.slug}`}
className="text-gray-900 hover:text-blue-600 dark:text-white dark:hover:text-blue-400"
>
{park.name}
</Link>
</h2>
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${statusClass}`}>
{formattedStatus}
</span>
</div>
{park.owner && (
<div className="text-sm mb-2">
<Link
href={`/companies/${park.owner.slug}`}
className="text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
>
{park.owner.name}
</Link>
</div>
)}
<div className="text-sm text-gray-600 space-x-4">
{park.location && (
<span>
{[
park.location.city,
park.location.state,
park.location.country
].filter(Boolean).join(', ')}
</span>
)}
<span>{park.ride_count} rides</span>
{park.opening_date && (
<span>Opened: {new Date(park.opening_date).toLocaleDateString()}</span>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,105 @@
'use client';
import { useState, useCallback } from 'react';
import debounce from 'lodash/debounce';
interface ParkSearchProps {
onSearch: (query: string) => void;
}
export function ParkSearch({ onSearch }: ParkSearchProps) {
const [query, setQuery] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [suggestions, setSuggestions] = useState<Array<{ id: string; name: string; slug: string }>>([]);
const debouncedFetchSuggestions = useCallback(
debounce(async (searchQuery: string) => {
if (!searchQuery.trim()) {
setSuggestions([]);
return;
}
try {
setIsLoading(true);
const response = await fetch(`/api/parks/suggest?search=${encodeURIComponent(searchQuery)}`);
const data = await response.json();
if (data.success) {
setSuggestions(data.data || []);
}
} catch (error) {
console.error('Failed to fetch suggestions:', error);
setSuggestions([]);
} finally {
setIsLoading(false);
}
}, 300),
[]
);
const handleSearch = (searchQuery: string) => {
setQuery(searchQuery);
debouncedFetchSuggestions(searchQuery);
onSearch(searchQuery);
};
const handleSuggestionClick = (suggestion: { name: string; slug: string }) => {
setQuery(suggestion.name);
setSuggestions([]);
onSearch(suggestion.name);
};
return (
<div className="max-w-3xl mx-auto relative mb-8">
<div className="w-full relative">
<div className="relative">
<input
type="search"
value={query}
onChange={(e) => handleSearch(e.target.value)}
placeholder="Search parks..."
className="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={suggestions.length > 0}
/>
{isLoading && (
<div
className="absolute right-3 top-1/2 -translate-y-1/2"
role="status"
aria-label="Loading search results"
>
<svg className="w-5 h-5 text-gray-400 animate-spin" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none"/>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
</svg>
<span className="sr-only">Searching...</span>
</div>
)}
</div>
{suggestions.length > 0 && (
<div
id="search-results"
className="absolute z-50 mt-1 w-full bg-white dark:bg-gray-800 rounded-md shadow-lg"
role="listbox"
>
<ul>
{suggestions.map((suggestion) => (
<li
key={suggestion.id}
className="px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer"
role="option"
onClick={() => handleSuggestionClick(suggestion)}
>
{suggestion.name}
</li>
))}
</ul>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,48 @@
'use client';
interface ViewToggleProps {
currentView: 'grid' | 'list';
onViewChange: (view: 'grid' | 'list') => void;
}
export function ViewToggle({ currentView, onViewChange }: ViewToggleProps) {
return (
<fieldset className="flex items-center space-x-2 bg-gray-100 rounded-lg p-1">
<legend className="sr-only">View mode selection</legend>
<button
onClick={() => onViewChange('grid')}
className={`p-2 rounded transition-colors duration-200 ${
currentView === 'grid' ? 'bg-white shadow-sm' : ''
}`}
aria-label="Grid view"
aria-pressed={currentView === 'grid'}
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M4 6h16M4 10h16M4 14h16M4 18h16"
/>
</svg>
</button>
<button
onClick={() => onViewChange('list')}
className={`p-2 rounded transition-colors duration-200 ${
currentView === 'list' ? 'bg-white shadow-sm' : ''
}`}
aria-label="List view"
aria-pressed={currentView === 'list'}
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M4 6h16M4 12h7"
/>
</svg>
</button>
</fieldset>
);
}

View File

@@ -0,0 +1,25 @@
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export async function middleware(request: NextRequest) {
const response = NextResponse.next();
// Add additional headers
response.headers.set('x-middleware-cache', 'no-cache');
// CORS headers for API routes
if (request.nextUrl.pathname.startsWith('/api/')) {
response.headers.set('Access-Control-Allow-Origin', '*');
response.headers.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
response.headers.set('Access-Control-Allow-Headers', 'Content-Type, Authorization');
}
return response;
}
// Configure routes that need middleware
export const config = {
matcher: [
'/api/:path*',
]
};

View File

@@ -0,0 +1,38 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function reset() {
try {
console.log('Starting database reset...');
// Drop all tables in the correct order
const tableOrder = [
'Photo',
'Review',
'ParkArea',
'Park',
'Company',
'User'
];
for (const table of tableOrder) {
console.log(`Deleting all records from ${table}...`);
// @ts-ignore - Dynamic table name
await prisma[table.toLowerCase()].deleteMany();
}
console.log('Database reset complete. Running seed...');
// Run the seed script
await import('../prisma/seed');
console.log('Database reset and seed completed successfully');
} catch (error) {
console.error('Error during database reset:', error);
process.exit(1);
} finally {
await prisma.$disconnect();
}
}
reset();

83
frontend/src/types/api.ts Normal file
View File

@@ -0,0 +1,83 @@
// General API response types
export interface ApiResponse<T> {
success: boolean;
data?: T;
error?: string;
}
// Park status type
export type ParkStatus =
| 'OPERATING'
| 'CLOSED_TEMP'
| 'CLOSED_PERM'
| 'UNDER_CONSTRUCTION'
| 'DEMOLISHED'
| 'RELOCATED';
// Company (owner) type
export interface Company {
id: string;
name: string;
slug: string;
}
// Location type
export interface Location {
id: string;
city?: string;
state?: string;
country?: string;
postal_code?: string;
street_address?: string;
latitude?: number;
longitude?: number;
}
// Park type
export interface Park {
id: string;
name: string;
slug: string;
description?: string;
status: ParkStatus;
owner?: Company;
location?: Location;
opening_date?: string;
closing_date?: string;
operating_season?: string;
website?: string;
size_acres?: number;
ride_count: number;
coaster_count?: number;
average_rating?: number;
}
// Park filter values type
export interface ParkFilterValues {
search?: string;
status?: ParkStatus;
ownerId?: string;
hasOwner?: boolean;
minRides?: number;
minCoasters?: number;
minSize?: number;
openingDateStart?: string;
openingDateEnd?: string;
}
// Park list response type
export type ParkListResponse = ApiResponse<Park[]>;
// Park suggestion response type
export interface ParkSuggestion {
id: string;
name: string;
slug: string;
status: ParkStatus;
owner?: {
name: string;
slug: string;
};
}
export type ParkSuggestionResponse = ApiResponse<ParkSuggestion[]>;

View File

@@ -0,0 +1,18 @@
import type { Config } from "tailwindcss";
export default {
content: [
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
colors: {
background: "var(--background)",
foreground: "var(--foreground)",
},
},
},
plugins: [],
} satisfies Config;

27
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

View File

@@ -0,0 +1,28 @@
# Generated by Django 5.1.4 on 2025-02-21 17:55
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("history_tracking", "0001_initial"),
]
operations = [
migrations.RenameIndex(
model_name="historicalslug",
new_name="history_tra_content_63013c_idx",
old_name="history_tra_content_1234ab_idx",
),
migrations.RenameIndex(
model_name="historicalslug",
new_name="history_tra_slug_f843aa_idx",
old_name="history_tra_slug_1234ab_idx",
),
migrations.AlterField(
model_name="historicalslug",
name="created_at",
field=models.DateTimeField(auto_now_add=True),
),
]

View File

@@ -0,0 +1,20 @@
# Generated by Django 5.1.4 on 2025-02-21 17:55
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("location", "0001_initial"),
]
operations = [
migrations.AlterField(
model_name="location",
name="id",
field=models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
]

View File

@@ -0,0 +1,20 @@
# Generated by Django 5.1.4 on 2025-02-21 17:55
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("media", "0001_initial"),
]
operations = [
migrations.AlterField(
model_name="photo",
name="id",
field=models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
]

View File

@@ -1,28 +1,127 @@
# Active Context - Park View Modularization # Active Context
**Objective:** Refactor parks view to use reusable card component and implement grid/list view toggle ## Current Status (Updated 2/23/2025 3:41 PM)
**Current Implementation Analysis:** ### API Test Results
- Park cards rendered via `park_list_item.html` partial ✅ GET /api/parks
- Existing layout uses flex-based list structure - Returns paginated list of parks
- Search functionality uses HTMX for dynamic updates - Includes relationships (areas, reviews, photos)
- Proper metadata with total count
- Type-safe response structure
**Planned Changes:** ✅ Search Parameters
1. **Create `park_card.html` Partial** - ?search=universal returns matching parks
- Extract card markup from `park_list_item.html` - ?page and ?limit for pagination
- Add responsive grid/list view classes - Case-insensitive search
- Include view mode toggle state
2. **View Toggle Implementation** ✅ POST /api/parks
- Add grid/list toggle UI with HTMX - Correctly enforces authentication
- Store view preference in cookie/localStorage - Returns 401 for unauthorized requests
- Update CSS for grid (grid-cols) vs list (flex) layouts - Validates required fields
3. **Backend Updates** ❌ Park Detail Routes
- Add view_mode parameter to park list view - /parks/[slug] returns 404
- Modify context processor to handle layout preference - Need to implement park detail API
- Need to create park detail page
**Next Steps:** ### Working Features
- Implement card partial with responsive classes 1. Parks API
- Create view toggle component - GET /api/parks with full data
- Update HTMX handlers to preserve view mode - Search and pagination
- Protected POST endpoint
- Error handling
2. Parks Listing
- Displays all parks
- Responsive grid layout
- Status badge with colors
- Loading states
- Error handling
### Immediate Next Steps
1. Park Detail Implementation (High Priority)
- [x] Create /api/parks/[slug] endpoint
- [x] Define response schema in api.ts
- [x] Implement GET handler in route.ts
- [x] Add error handling for invalid slugs
- [x] Add park detail page component
- [x] Create parks/[slug]/page.tsx
- [x] Implement data fetching with loading state
- [x] Add error boundary handling
- [x] Handle loading states
- [x] Create loading.tsx skeleton
- [x] Implement suspense boundaries
- [ ] Add reviews section
- [ ] Create reviews component
- [ ] Add reviews API endpoint
2. Authentication (High Priority)
- [ ] Implement JWT token management
- [ ] Set up JWT middleware
- [ ] Add token refresh handling
- [ ] Store tokens securely
- [ ] Add login/register forms
- [ ] Create form components with validation
- [ ] Add form submission handlers
- [ ] Implement success/error states
- [ ] Protected route middleware
- [ ] Set up middleware.ts checks
- [ ] Add authentication redirect logic
- [ ] Auth context provider
- [ ] Create auth state management
- [ ] Add context hooks for components
3. UI Improvements (Medium Priority)
- [ ] Add search input in UI
- [ ] Create reusable search component
- [ ] Implement debounced API calls
- [ ] Implement filter controls
- [ ] Add filter state management
- [ ] Create filter UI components
- [ ] Add proper loading skeletons
- [ ] Design consistent skeleton layouts
- [ ] Implement skeleton components
- [ ] Improve error messages
- [ ] Create error message component
- [ ] Add error status pages
### Known Issues
1. No authentication system yet
2. Missing park detail views
3. No form validation
4. No image upload handling
5. No real-time updates
6. Static metadata (page size)
### Required Documentation
1. API Endpoints
- ✅ GET /api/parks
- ✅ POST /api/parks
- ❌ GET /api/parks/[slug]
- ❌ PUT /api/parks/[slug]
- ❌ DELETE /api/parks/[slug]
2. Component Documentation
- ❌ Parks list component
- ❌ Park card component
- ❌ Status badge component
- ❌ Loading states
3. Authentication Flow
- ❌ JWT implementation
- ❌ Protected routes
- ❌ Auth context
- ❌ Login/Register forms
## Configuration
- Next.js 15.1.7
- Prisma with PostGIS
- PostgreSQL database
- REST API patterns
## Notes
1. Authentication needed before implementing write operations
2. Consider caching for park data
3. Need to implement proper error logging
4. Consider rate limiting for API

View File

@@ -0,0 +1,254 @@
# Laravel Migration Analysis
## Executive Summary
After thorough analysis of the ThrillWiki Django codebase, this document presents a comprehensive evaluation of migrating to Laravel. The analysis considers technical compatibility, implementation impact, and business implications.
### Quick Overview
**Current Stack:**
- Framework: Django (MVT Architecture)
- Frontend: HTMX + AlpineJS + Tailwind CSS
- Database: PostgreSQL with Django ORM
- Authentication: Django Built-in Auth
**Recommendation:** ⛔️ DO NOT PROCEED with Laravel migration
The analysis reveals that the costs, risks, and disruption of migration outweigh potential benefits, particularly given the project's mature Django codebase and specialized features.
## Technical Analysis
### Core Functionality Compatibility
#### Data Model Migration Complexity: HIGH
- Complex Django models with inheritance (TrackedModel)
- Custom user model with role-based permissions
- Extensive use of Django-specific model features
- Migration challenges:
* Different ORM paradigms
* Custom model behaviors
* Signal system reimplementation
* Complex queries and annotations
#### Authentication System: HIGH
- Currently leverages Django's auth framework extensively
- Custom adapters for social authentication
- Role-based permission system
- Migration challenges:
* Laravel's auth system differs fundamentally
* Custom middleware rewrites needed
* Session handling differences
* Social auth integration rework
#### Template Engine: MEDIUM
- Heavy use of Django template inheritance
- HTMX integration for dynamic updates
- Migration challenges:
* Blade syntax differences
* Different template inheritance patterns
* HTMX integration patterns
* Custom template tags rewrite
#### ORM and Database Layer: VERY HIGH
- Extensive use of Django ORM features
- Complex model relationships
- Custom model managers
- Migration challenges:
* Different query builder syntax
* Relationship definition differences
* Transaction handling variations
* Custom field type conversions
### Architecture Impact
#### Routing and Middleware: HIGH
- Complex URL patterns with nested resources
- Custom middleware for analytics and tracking
- Migration challenges:
* Different routing paradigms
* Middleware architecture differences
* Request/Response cycle variations
#### File Structure Changes: MEDIUM
- Current Django apps need restructuring
- Different convention requirements
- Migration challenges:
* Resource organization
* Namespace handling
* Service provider implementation
#### API and Service Layer: HIGH
- Custom API implementation
- Complex service layer integration
- Migration challenges:
* Different API architecture
* Service container differences
* Dependency injection patterns
## Implementation Impact
### Development Timeline
Estimated timeline: 4-6 months minimum
- Phase 1 (Data Layer): 6-8 weeks
- Phase 2 (Business Logic): 8-10 weeks
- Phase 3 (Frontend Integration): 4-6 weeks
- Phase 4 (Testing & Deployment): 4-6 weeks
### Resource Requirements
- 2-3 Senior Laravel Developers
- 1 DevOps Engineer
- 1 QA Engineer
- Project Manager
### Testing Strategy Updates
- Complete test suite rewrite needed
- New testing frameworks required
- Integration test complexity
- Performance testing rework
### Deployment Modifications
- CI/CD pipeline updates
- Environment configuration changes
- Server requirement updates
- Monitoring system adjustments
## Business Impact
### Cost Analysis
1. Direct Costs:
- Development Resources: ~$150,000-200,000
- Training: ~$20,000
- Infrastructure Updates: ~$10,000
- Total: ~$180,000-230,000
2. Indirect Costs:
- Productivity loss during transition
- Potential downtime
- Bug risk increase
- Learning curve impact
### Risk Assessment
#### Technical Risks (HIGH)
- Data integrity during migration
- Performance regressions
- Unknown edge cases
- Integration failures
#### Business Risks (HIGH)
- Service disruption
- Feature parity gaps
- User experience inconsistency
- Timeline uncertainty
#### Mitigation Strategies
- Phased migration approach
- Comprehensive testing
- Rollback procedures
- User communication plan
## Detailed Technical Challenges
### Critical Areas
1. History Tracking System
- Custom implementation in Django
- Complex diff tracking
- Temporal data management
2. Authentication System
- Role-based access control
- Social authentication integration
- Custom user profiles
3. Geographic Features
- Location services
- Coordinate normalization
- Geographic queries
4. Media Management
- Custom storage backends
- Image processing
- Upload handling
## Conclusion
### Key Findings
1. High Technical Debt: Migration would require substantial rewrite
2. Complex Domain Logic: Specialized features need careful translation
3. Resource Intensive: Significant time and budget required
4. High Risk: Critical business functions affected
### Recommendation
**Do Not Proceed with Migration**
Rationale:
1. Current Django implementation is stable and mature
2. Migration costs outweigh potential benefits
3. High risk to business continuity
4. Significant resource requirement
### Alternative Recommendations
1. **Modernize Current Stack**
- Update Django version
- Enhance current architecture
- Improve performance in place
2. **Gradual Enhancement**
- Add Laravel microservices if needed
- Keep core Django system
- Hybrid approach for new features
3. **Focus on Business Value**
- Invest in feature development
- Improve user experience
- Enhance current system
## Success Metrics (If Migration Proceeded)
1. Technical Metrics
- Performance parity or improvement
- Code quality metrics
- Test coverage
- Deployment success rate
2. Business Metrics
- User satisfaction
- System availability
- Feature parity
- Development velocity
## Timeline and Resource Allocation
### Phase 1: Planning and Setup (4-6 weeks)
- Architecture design
- Environment setup
- Team training
### Phase 2: Core Migration (12-16 weeks)
- Database migration
- Authentication system
- Core business logic
### Phase 3: Frontend Integration (8-10 weeks)
- Template conversion
- HTMX integration
- UI testing
### Phase 4: Testing and Deployment (6-8 weeks)
- System testing
- Performance optimization
- Production deployment
### Total Timeline: 30-40 weeks
## Final Verdict
Given the extensive analysis, the recommendation is to **maintain and enhance the current Django implementation** rather than pursuing a Laravel migration. The current system is stable, well-architected, and effectively serves business needs. The high costs, risks, and potential disruption of migration outweigh any potential benefits that Laravel might offer.
Focus should instead be directed toward:
1. Optimizing current Django implementation
2. Enhancing feature set and user experience
3. Updating dependencies and security
4. Improving development workflows

View File

@@ -0,0 +1,146 @@
# Next.js Migration Plan
## Overview
This document outlines the strategy for migrating ThrillWiki from a Django monolith to a Next.js frontend with API Routes backend while maintaining all existing functionality and design.
## Current Architecture
- Django monolithic application
- Django templates with HTMX and Alpine.js
- Django views handling both API and page rendering
- Django ORM for database operations
- Custom analytics system
- File upload handling through Django
- Authentication through Django
## Target Architecture
- Next.js 14+ application using App Router
- React components replacing Django templates
- Next.js API Routes replacing Django views
- Prisma ORM replacing Django ORM
- JWT-based authentication system
- Maintain current DB schema
- API-first approach with type safety
- File uploads through Next.js API routes
## Component Mapping
Major sections requiring migration:
1. Parks System:
- Convert Django views to API routes
- Convert templates to React components
- Implement dynamic routing
- Maintain search functionality
2. User System:
- Implement JWT authentication
- Convert user management to API routes
- Migrate profile management
- Handle avatar uploads
3. Reviews System:
- Convert to API routes
- Implement real-time updates
- Maintain moderation features
4. Analytics:
- Convert to API routes
- Implement client-side tracking
- Maintain current metrics
## API Route Mapping
```typescript
// Example API route structure
/api
/auth
/login
/register
/profile
/parks
/[id]
/search
/nearby
/reviews
/[id]
/create
/moderate
/analytics
/track
/stats
```
## Migration Phases
### Phase 1: Setup & Infrastructure
1. Initialize Next.js project
2. Set up Prisma with existing schema
3. Configure TypeScript
4. Set up authentication system
5. Configure file upload handling
### Phase 2: Core Features
1. Parks system migration
2. User authentication
3. Basic CRUD operations
4. Search functionality
5. File uploads
### Phase 3: Advanced Features
1. Reviews system
2. Analytics
3. Moderation tools
4. Real-time features
5. Admin interfaces
### Phase 4: Testing & Polish
1. Comprehensive testing
2. Performance optimization
3. SEO implementation
4. Security audit
5. Documentation updates
## Dependencies
### Frontend
- next: ^14.0.0
- react: ^18.2.0
- react-dom: ^18.2.0
- @prisma/client
- @tanstack/react-query
- tailwindcss
- typescript
- zod (for validation)
- jwt-decode
- @headlessui/react
### Backend
- prisma
- jsonwebtoken
- bcryptjs
- multer (file uploads)
- sharp (image processing)
## Data Migration Strategy
1. Create Prisma schema matching Django models
2. Write migration scripts for data transfer
3. Validate data integrity
4. Implement rollback procedures
## Security Considerations
1. JWT token handling
2. CSRF protection
3. Rate limiting
4. File upload security
5. API route protection
## Performance Optimization
1. Implement ISR (Incremental Static Regeneration)
2. Optimize images and assets
3. Implement caching strategy
4. Code splitting
5. Bundle optimization
## Rollback Plan
1. Maintain dual systems during migration
2. Database backup strategy
3. Traffic routing plan
4. Monitoring and alerts

View 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

View 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

View File

@@ -0,0 +1,410 @@
# API Documentation
## API Overview
### Base Configuration
```python
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': [
'rest_framework_jwt.authentication.JSONWebTokenAuthentication',
'rest_framework.authentication.SessionAuthentication',
],
'DEFAULT_PERMISSION_CLASSES': [
'rest_framework.permissions.IsAuthenticated',
],
'DEFAULT_PAGINATION_CLASS':
'rest_framework.pagination.PageNumberPagination',
'PAGE_SIZE': 20,
'DEFAULT_VERSIONING_CLASS':
'rest_framework.versioning.AcceptHeaderVersioning',
'DEFAULT_VERSION': 'v1'
}
```
## Authentication
### JWT Authentication
```http
POST /api/token/
Content-Type: application/json
{
"username": "user@example.com",
"[PASSWORD-REMOVED]"
}
Response:
{
"access": "eyJ0eXAiOiJKV1QiLCJhbGc...",
"refresh": "eyJ0eXAiOiJKV1QiLCJhbGc..."
}
```
### Token Refresh
```http
POST /api/token/refresh/
Content-Type: application/json
{
"refresh": "eyJ0eXAiOiJKV1QiLCJhbGc..."
}
Response:
{
"access": "eyJ0eXAiOiJKV1QiLCJhbGc..."
}
```
## Endpoints
### Parks API
#### List Parks
```http
GET /api/v1/parks/
Authorization: Bearer <token>
Response:
{
"count": 100,
"next": "http://api.thrillwiki.com/parks/?page=2",
"previous": null,
"results": [
{
"id": 1,
"name": "Adventure Park",
"slug": "adventure-park",
"status": "OPERATING",
"description": "...",
"location": {
"city": "Orlando",
"state": "FL",
"country": "USA"
},
"ride_count": 25,
"average_rating": 4.5
}
]
}
```
#### Get Park Detail
```http
GET /api/v1/parks/{slug}/
Authorization: Bearer <token>
Response:
{
"id": 1,
"name": "Adventure Park",
"slug": "adventure-park",
"status": "OPERATING",
"description": "...",
"location": {
"address": "123 Theme Park Way",
"city": "Orlando",
"state": "FL",
"country": "USA",
"postal_code": "32819",
"coordinates": {
"latitude": 28.538336,
"longitude": -81.379234
}
},
"owner": {
"id": 1,
"name": "Theme Park Corp",
"verified": true
},
"stats": {
"ride_count": 25,
"coaster_count": 5,
"average_rating": 4.5
},
"rides": [
{
"id": 1,
"name": "Thrill Coaster",
"type": "ROLLER_COASTER",
"status": "OPERATING"
}
]
}
```
### Rides API
#### List Rides
```http
GET /api/v1/parks/{park_slug}/rides/
Authorization: Bearer <token>
Response:
{
"count": 25,
"next": null,
"previous": null,
"results": [
{
"id": 1,
"name": "Thrill Coaster",
"slug": "thrill-coaster",
"type": "ROLLER_COASTER",
"status": "OPERATING",
"height_requirement": 48,
"thrill_rating": 5,
"manufacturer": {
"id": 1,
"name": "Coaster Corp"
}
}
]
}
```
#### Get Ride Detail
```http
GET /api/v1/rides/{ride_slug}/
Authorization: Bearer <token>
Response:
{
"id": 1,
"name": "Thrill Coaster",
"slug": "thrill-coaster",
"type": "ROLLER_COASTER",
"status": "OPERATING",
"description": "...",
"specifications": {
"height_requirement": 48,
"thrill_rating": 5,
"capacity_per_hour": 1200,
"track_length": 3000
},
"manufacturer": {
"id": 1,
"name": "Coaster Corp"
},
"designer": {
"id": 1,
"name": "John Designer"
},
"opening_date": "2020-06-15",
"stats": {
"average_rating": 4.8,
"review_count": 150
}
}
```
### Reviews API
#### Create Review
```http
POST /api/v1/reviews/
Authorization: Bearer <token>
Content-Type: application/json
{
"content_type": "ride",
"object_id": 1,
"rating": 5,
"content": "Amazing experience!",
"media": [
{
"type": "image",
"file": "base64encoded..."
}
]
}
Response:
{
"id": 1,
"author": {
"id": 1,
"username": "reviewer"
},
"rating": 5,
"content": "Amazing experience!",
"status": "PENDING",
"created_at": "2024-02-18T14:30:00Z"
}
```
#### List Reviews
```http
GET /api/v1/rides/{ride_id}/reviews/
Authorization: Bearer <token>
Response:
{
"count": 150,
"next": "http://api.thrillwiki.com/rides/1/reviews/?page=2",
"previous": null,
"results": [
{
"id": 1,
"author": {
"id": 1,
"username": "reviewer"
},
"rating": 5,
"content": "Amazing experience!",
"created_at": "2024-02-18T14:30:00Z",
"media": [
{
"type": "image",
"url": "https://media.thrillwiki.com/reviews/1/image.jpg"
}
]
}
]
}
```
## Integrations
### Email Service Integration
```http
POST /api/v1/email/send/
Authorization: Bearer <token>
Content-Type: application/json
{
"template": "review_notification",
"recipient": "user@example.com",
"context": {
"review_id": 1,
"content": "Amazing experience!"
}
}
Response:
{
"status": "sent",
"message_id": "123abc",
"sent_at": "2024-02-18T14:30:00Z"
}
```
### Media Processing
```http
POST /api/v1/media/process/
Authorization: Bearer <token>
Content-Type: multipart/form-data
file: [binary data]
Response:
{
"id": 1,
"original_url": "https://media.thrillwiki.com/original/image.jpg",
"processed_url": "https://media.thrillwiki.com/processed/image.jpg",
"thumbnail_url": "https://media.thrillwiki.com/thumbnails/image.jpg",
"metadata": {
"width": 1920,
"height": 1080,
"format": "jpeg",
"size": 1024576
}
}
```
## API Versioning
### Version Header
```http
Accept: application/json; version=1.0
```
### Version Routes
```python
# urls.py
urlpatterns = [
path('v1/', include('api.v1.urls')),
path('v2/', include('api.v2.urls')),
]
```
## Error Handling
### Error Response Format
```json
{
"error": {
"code": "validation_error",
"message": "Invalid input data",
"details": [
{
"field": "rating",
"message": "Rating must be between 1 and 5"
}
]
}
}
```
### Common Error Codes
- `authentication_error`: Invalid or missing authentication
- `permission_denied`: Insufficient permissions
- `validation_error`: Invalid input data
- `not_found`: Resource not found
- `rate_limit_exceeded`: Too many requests
## Rate Limiting
### Rate Limit Configuration
```python
REST_FRAMEWORK = {
'DEFAULT_THROTTLE_CLASSES': [
'rest_framework.throttling.AnonRateThrottle',
'rest_framework.throttling.UserRateThrottle'
],
'DEFAULT_THROTTLE_RATES': {
'anon': '100/day',
'user': '1000/day',
'burst': '20/minute'
}
}
```
### Rate Limit Headers
```http
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 999
X-RateLimit-Reset: 1613664000
```
## API Documentation
### Swagger/OpenAPI
```yaml
openapi: 3.0.0
info:
title: ThrillWiki API
version: 1.0.0
paths:
/parks:
get:
summary: List parks
parameters:
- name: page
in: query
schema:
type: integer
responses:
'200':
description: Successful response
content:
application/json:
schema:
$ref: '#/components/schemas/ParkList'
```
### API Documentation URLs
```python
urlpatterns = [
path('docs/', include_docs_urls(title='ThrillWiki API')),
path('schema/', schema_view),
]

View File

@@ -0,0 +1,168 @@
# System Architecture Documentation
## Overview
ThrillWiki is a Django-based web platform built with a modular architecture focusing on theme park information management, user reviews, and content moderation.
## Technology Stack
### Backend
- **Framework**: Django 5.1.6
- **API**: Django REST Framework 3.15.2
- **WebSocket Support**: Channels 4.2.0 with Redis
- **Authentication**: django-allauth, OAuth Toolkit
- **Database**: PostgreSQL with django-pghistory
### Frontend
- **Templating**: Django Templates
- **CSS Framework**: Tailwind CSS
- **Enhancement**: HTMX, JavaScript
- **Asset Management**: django-webpack-loader
### Infrastructure
- **Static Files**: WhiteNoise 6.9.0
- **Media Storage**: Local filesystem with custom storage backends
- **Caching**: Redis (shared with WebSocket layer)
## System Components
### Core Applications
1. **Parks Module**
- Park information management
- Geographic data handling
- Operating hours tracking
- Integration with location services
2. **Rides Module**
- Ride specifications
- Manufacturer/Designer attribution
- Historical data tracking
- Technical details management
3. **Reviews System**
- User-generated content
- Media attachments
- Rating framework
- Integration with moderation
4. **Moderation System**
- Content review workflow
- Quality control mechanisms
- User management
- Verification processes
5. **Companies Module**
- Company profiles
- Verification system
- Official update management
- Park operator features
### Service Layer
1. **Authentication Service**
```python
# Key authentication flows
User Authentication → JWT Token → Protected Resources
Social Auth → Profile Creation → Platform Access
```
2. **Media Service**
```python
# Media handling workflow
Upload → Processing → Storage → Delivery
```
3. **Analytics Service**
```python
# Analytics pipeline
User Action → Event Tracking → Processing → Insights
```
## Data Flow Architecture
```
┌─────────────┐ ┌──────────────┐ ┌─────────────┐
│ Client │ ──→ │ Django │ ──→ │ Database │
│ Browser │ ←── │ Server │ ←── │ (Postgres) │
└─────────────┘ └──────────────┘ └─────────────┘
↑ ↓
┌──────────────┐
│ Services │
│ (Redis/S3) │
└──────────────┘
```
## Security Architecture
1. **Authentication Flow**
- JWT-based authentication
- Social authentication integration
- Session management
- Permission-based access control
2. **Data Protection**
- Input validation
- XSS prevention
- CSRF protection
- SQL injection prevention
## Deployment Model
### Production Environment
```
├── Application Server (Daphne/ASGI)
├── Database (PostgreSQL)
├── Cache/Message Broker (Redis)
├── Static Files (WhiteNoise)
└── Media Storage (Filesystem/S3)
```
### Development Environment
```
├── Local Django Server
├── Local PostgreSQL
├── Local Redis
└── Local File Storage
```
## Monitoring and Scaling
1. **Performance Monitoring**
- Page load metrics
- Database query analysis
- Cache hit rates
- API response times
2. **Scaling Strategy**
- Horizontal scaling of web servers
- Database read replicas
- Cache layer expansion
- Media CDN integration
## Integration Points
1. **External Services**
- Email service (ForwardEmail.net)
- Social authentication providers
- Geographic data services
- Media processing services
2. **Internal Services**
- WebSocket notifications
- Background tasks
- Media processing
- Analytics processing
## System Requirements
### Minimum Requirements
- Python 3.11+
- PostgreSQL 13+
- Redis 6+
- Node.js 18+ (for frontend builds)
### Development Tools
- black (code formatting)
- flake8 (linting)
- pytest (testing)
- tailwind CLI (CSS processing)

View File

@@ -0,0 +1,287 @@
# Code Documentation
## Project Structure
```
thrillwiki/
├── accounts/ # User management
├── analytics/ # Usage tracking
├── companies/ # Company profiles
├── core/ # Core functionality
├── designers/ # Designer profiles
├── email_service/ # Email handling
├── history/ # Historical views
├── history_tracking/ # Change tracking
├── location/ # Geographic features
├── media/ # Media management
├── moderation/ # Content moderation
├── parks/ # Park management
├── reviews/ # Review system
└── rides/ # Ride management
```
## Code Patterns
### 1. Model Patterns
#### History Tracking
```python
@pghistory.track()
class TrackedModel(models.Model):
"""Base class for models with history tracking"""
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
```
#### Slug Management
```python
class SluggedModel:
"""Pattern for models with slug-based URLs"""
@classmethod
def get_by_slug(cls, slug: str) -> Tuple[Model, bool]:
# Check current slugs
try:
return cls.objects.get(slug=slug), False
except cls.DoesNotExist:
# Check historical slugs
historical = HistoricalSlug.objects.filter(
content_type=ContentType.objects.get_for_model(cls),
slug=slug
).first()
if historical:
return cls.objects.get(pk=historical.object_id), True
```
#### Generic Relations
```python
# Example from parks/models.py
class Park(TrackedModel):
location = GenericRelation(Location)
photos = GenericRelation(Photo)
```
### 2. View Patterns
#### Class-Based Views
```python
class ModeratedCreateView(LoginRequiredMixin, CreateView):
"""Base view for content requiring moderation"""
def form_valid(self, form):
obj = form.save(commit=False)
obj.status = 'PENDING'
obj.created_by = self.request.user
return super().form_valid(form)
```
#### Permission Mixins
```python
class ModeratorRequiredMixin:
"""Ensures user has moderation permissions"""
def dispatch(self, request, *args, **kwargs):
if not request.user.has_perm('moderation.can_moderate'):
raise PermissionDenied
return super().dispatch(request, *args, **kwargs)
```
### 3. Service Patterns
#### Email Service
```python
class EmailService:
"""Handles email templating and sending"""
def send_moderation_notification(self, content):
template = 'moderation/email/notification.html'
context = {'content': content}
self.send_templated_email(template, context)
```
#### Media Processing
```python
class MediaProcessor:
"""Handles image optimization and processing"""
def process_image(self, image):
# Optimize size
# Extract EXIF
# Generate thumbnails
return processed_image
```
## Dependencies
### Core Dependencies
```toml
# From pyproject.toml
[tool.poetry.dependencies]
django = "5.1.6"
djangorestframework = "3.15.2"
django-allauth = "65.4.1"
psycopg2-binary = "2.9.10"
django-pghistory = "3.5.2"
```
### Frontend Dependencies
```json
{
"tailwindcss": "^3.0.0",
"htmx": "^1.22.0",
"webpack": "^5.0.0"
}
```
## Build Configuration
### Django Settings
```python
INSTALLED_APPS = [
# Django apps
'django.contrib.admin',
'django.contrib.auth',
# Third-party apps
'allauth',
'rest_framework',
'corsheaders',
# Local apps
'parks.apps.ParksConfig',
'rides.apps.RidesConfig',
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'whitenoise.middleware.WhiteNoiseMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
]
```
### Database Configuration
```python
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': env('DB_NAME'),
'USER': env('DB_USER'),
'PASSWORD': env('DB_PASSWORD'),
'HOST': env('DB_HOST'),
'PORT': env('DB_PORT'),
}
}
```
## Testing Framework
### Test Structure
```
tests/
├── unit/ # Unit tests
├── integration/ # Integration tests
└── e2e/ # End-to-end tests
```
### Test Patterns
```python
class ParkTestCase(TestCase):
def setUp(self):
self.park = Park.objects.create(
name="Test Park",
status="OPERATING"
)
def test_park_creation(self):
self.assertEqual(self.park.slug, "test-park")
```
## Package Management
### Python Dependencies
```bash
# Development dependencies
pip install -r requirements-dev.txt
# Production dependencies
pip install -r requirements.txt
```
### Frontend Build
```bash
# Install frontend dependencies
npm install
# Build static assets
npm run build
```
## Code Quality Tools
### Python Tools
- black (code formatting)
- flake8 (linting)
- mypy (type checking)
- pytest (testing)
### Configuration Files
```toml
# pyproject.toml
[tool.black]
line-length = 88
target-version = ['py311']
[tool.mypy]
plugins = ["mypy_django_plugin.main"]
```
## Development Workflow
### Local Development
1. Set up virtual environment
2. Install dependencies
3. Run migrations
4. Start development server
```bash
python -m venv venv
source venv/bin/activate
pip install -r requirements.txt
python manage.py migrate
python manage.py runserver
```
### Code Review Process
1. Run linting tools
2. Run test suite
3. Check type hints
4. Review documentation
## Deployment Process
### Pre-deployment Checks
1. Run test suite
2. Check migrations
3. Validate static files
4. Verify environment variables
### Deployment Steps
1. Update dependencies
2. Apply migrations
3. Collect static files
4. Restart application server
## Error Handling
### Exception Pattern
```python
class CustomException(Exception):
"""Base exception for application"""
def __init__(self, message, code=None):
self.message = message
self.code = code
```
### Middleware Pattern
```python
class ErrorHandlingMiddleware:
"""Centralized error handling"""
def process_exception(self, request, exception):
# Log exception
# Handle gracefully
# Return appropriate response

View File

@@ -0,0 +1,327 @@
# Data Documentation
## Database Schema
### Core Models
#### Parks
```sql
CREATE TABLE parks_park (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
slug VARCHAR(255) UNIQUE NOT NULL,
description TEXT,
status VARCHAR(20) DEFAULT 'OPERATING',
opening_date DATE,
closing_date DATE,
operating_season VARCHAR(255),
size_acres DECIMAL(10,2),
website VARCHAR(200),
average_rating DECIMAL(3,2),
ride_count INTEGER,
coaster_count INTEGER,
owner_id INTEGER REFERENCES companies_company(id),
created_at TIMESTAMP,
updated_at TIMESTAMP
);
```
#### Rides
```sql
CREATE TABLE rides_ride (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
slug VARCHAR(255) NOT NULL,
description TEXT,
status VARCHAR(20),
park_id INTEGER REFERENCES parks_park(id),
area_id INTEGER REFERENCES parks_parkarea(id),
manufacturer_id INTEGER REFERENCES companies_company(id),
designer_id INTEGER REFERENCES designers_designer(id),
opening_date DATE,
closing_date DATE,
height_requirement INTEGER,
ride_type VARCHAR(50),
thrill_rating INTEGER,
created_at TIMESTAMP,
updated_at TIMESTAMP,
UNIQUE(park_id, slug)
);
```
#### Reviews
```sql
CREATE TABLE reviews_review (
id SERIAL PRIMARY KEY,
content TEXT NOT NULL,
rating DECIMAL(3,2),
status VARCHAR(20),
author_id INTEGER REFERENCES auth_user(id),
content_type_id INTEGER REFERENCES django_content_type(id),
object_id INTEGER,
created_at TIMESTAMP,
updated_at TIMESTAMP
);
```
### Entity Relationships
```mermaid
erDiagram
Park ||--o{ ParkArea : "contains"
Park ||--o{ Ride : "has"
Park ||--o{ Photo : "has"
Park ||--o{ Review : "receives"
ParkArea ||--o{ Ride : "contains"
Ride ||--o{ Photo : "has"
Ride ||--o{ Review : "receives"
Company ||--o{ Park : "owns"
Company ||--o{ Ride : "manufactures"
Designer ||--o{ Ride : "designs"
User ||--o{ Review : "writes"
```
## Data Models
### Content Models
#### Park Model
- Core information about theme parks
- Location data through GenericRelation
- Media attachments
- Historical tracking
- Owner relationship
#### Ride Model
- Technical specifications
- Park and area relationships
- Manufacturer and designer links
- Operation status tracking
- Safety requirements
#### Review Model
- Generic foreign key for flexibility
- Rating system
- Media attachments
- Moderation status
- Author tracking
### Supporting Models
#### Location Model
```python
class Location(models.Model):
content_type = models.ForeignKey(ContentType)
object_id = models.PositiveIntegerField()
content_object = GenericForeignKey()
address = models.CharField(max_length=255)
city = models.CharField(max_length=100)
state = models.CharField(max_length=100)
country = models.CharField(max_length=100)
postal_code = models.CharField(max_length=20)
latitude = models.DecimalField(max_digits=9, decimal_places=6)
longitude = models.DecimalField(max_digits=9, decimal_places=6)
```
#### Media Model
```python
class Photo(models.Model):
content_type = models.ForeignKey(ContentType)
object_id = models.PositiveIntegerField()
content_object = GenericForeignKey()
file = models.ImageField(upload_to='photos/')
caption = models.CharField(max_length=255)
taken_at = models.DateTimeField(null=True)
uploaded_at = models.DateTimeField(auto_now_add=True)
```
## Storage Strategies
### Database Storage
#### PostgreSQL Configuration
```python
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': 'thrillwiki',
'CONN_MAX_AGE': 60,
'OPTIONS': {
'client_encoding': 'UTF8',
},
}
}
```
#### Indexing Strategy
```sql
-- Performance indexes
CREATE INDEX idx_park_slug ON parks_park(slug);
CREATE INDEX idx_ride_slug ON rides_ride(slug);
CREATE INDEX idx_review_content_type ON reviews_review(content_type_id, object_id);
```
### File Storage
#### Media Storage
```python
# Media storage configuration
MEDIA_ROOT = os.path.join(BASE_DIR, 'media')
MEDIA_URL = '/media/'
# File upload handlers
FILE_UPLOAD_HANDLERS = [
'django.core.files.uploadhandler.MemoryFileUploadHandler',
'django.core.files.uploadhandler.TemporaryFileUploadHandler',
]
```
#### Directory Structure
```
media/
├── photos/
│ ├── parks/
│ ├── rides/
│ └── reviews/
├── avatars/
└── documents/
```
### Caching Strategy
#### Cache Configuration
```python
CACHES = {
'default': {
'BACKEND': 'django.core.cache.backends.redis.RedisCache',
'LOCATION': 'redis://127.0.0.1:6379/1',
'OPTIONS': {
'CLIENT_CLASS': 'django_redis.client.DefaultClient',
}
}
}
```
#### Cache Keys
```python
# Cache key patterns
CACHE_KEYS = {
'park_detail': 'park:{slug}',
'ride_list': 'park:{park_slug}:rides',
'review_count': 'content:{type}:{id}:reviews',
}
```
## Data Migration
### Migration Strategy
1. Schema migrations via Django
2. Data migrations for model changes
3. Content migrations for large updates
### Example Migration
```python
# migrations/0002_add_park_status.py
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('parks', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='park',
name='status',
field=models.CharField(
max_length=20,
choices=[
('OPERATING', 'Operating'),
('CLOSED', 'Closed'),
],
default='OPERATING'
),
),
]
```
## Data Protection
### Backup Strategy
1. Daily database backups
2. Media files backup
3. Retention policy management
### Backup Configuration
```python
# backup settings
BACKUP_ROOT = os.path.join(BASE_DIR, 'backups')
BACKUP_RETENTION_DAYS = 30
BACKUP_COMPRESSION = True
```
## Data Validation
### Model Validation
```python
class Park(models.Model):
def clean(self):
if self.closing_date and self.opening_date:
if self.closing_date < self.opening_date:
raise ValidationError({
'closing_date': 'Closing date cannot be before opening date'
})
```
### Form Validation
```python
class RideForm(forms.ModelForm):
def clean_height_requirement(self):
height = self.cleaned_data['height_requirement']
if height and height < 0:
raise forms.ValidationError('Height requirement cannot be negative')
return height
```
## Data Access Patterns
### QuerySet Optimization
```python
# Optimized query pattern
Park.objects.select_related('owner')\
.prefetch_related('rides', 'areas')\
.filter(status='OPERATING')
```
### Caching Pattern
```python
def get_park_detail(slug):
cache_key = f'park:{slug}'
park = cache.get(cache_key)
if not park:
park = Park.objects.get(slug=slug)
cache.set(cache_key, park, timeout=3600)
return park
```
## Monitoring and Metrics
### Database Metrics
- Query performance
- Cache hit rates
- Storage usage
- Connection pool status
### Collection Configuration
```python
LOGGING = {
'handlers': {
'db_log': {
'level': 'DEBUG',
'class': 'logging.handlers.RotatingFileHandler',
'filename': 'logs/db.log',
},
},
}

View File

@@ -0,0 +1,253 @@
# Feature Documentation
## Core Features
### 1. Park Management
#### Park Discovery
- Geographic search and filtering
- Park categorization and taxonomy
- Operating hours and seasonal information
- Location-based recommendations
#### Park Profiles
- Detailed park information
- Historical data and timeline
- Media galleries
- Operating schedule management
- Accessibility information
#### Area Management
```python
# Key relationships
Park
Areas
Rides
```
### 2. Ride System
#### Ride Catalog
- Technical specifications
- Thrill ratings and categories
- Operational status tracking
- Maintenance history
- Designer and manufacturer attribution
#### Ride Features
- Height requirements
- Accessibility options
- Queue management information
- Rider experience details
- Historical modifications
### 3. Review System
#### User Reviews
- Rating framework
- Experience descriptions
- Visit date tracking
- Media attachments
- Helpful vote system
#### Review Workflow
```
Submission → Moderation → Publication → Feedback
```
#### Review Features
- Rich text formatting
- Multi-media support
- Rating categories
- Experience verification
- Response management
### 4. User Management
#### User Profiles
- Activity history
- Contribution tracking
- Reputation system
- Privacy controls
#### Authentication
- Email registration
- Social authentication
- Password management
- Session control
#### Permissions
- Role-based access
- Content moderation rights
- Company verification
- Expert designation
### 5. Company Management
#### Company Profiles
- Official park operator accounts
- Manufacturer profiles
- Designer portfolios
- Verification system
#### Official Updates
- Park announcements
- Operational updates
- New attraction information
- Special event coverage
### 6. Media Management
#### Image Handling
- Multi-format support
- EXIF data processing
- Automatic optimization
- Gallery organization
#### Storage System
```python
# Media organization
content/
parks/
rides/
reviews/
profiles/
```
### 7. Location Services
#### Geographic Features
- Park proximity search
- Regional categorization
- Map integration
- Distance calculations
#### Location Data
- Coordinate system
- Address validation
- Region management
- Geographic clustering
### 8. Analytics System
#### Tracking Features
- Page view analytics
- User engagement metrics
- Content popularity
- Search patterns
#### Trend Analysis
- Popular content
- User behavior
- Seasonal patterns
- Content quality metrics
## Business Requirements
### 1. Content Quality
- Mandatory review fields
- Media quality standards
- Information verification
- Source attribution
### 2. User Trust
- Review authenticity checks
- Company verification process
- Expert contribution validation
- Content moderation workflow
### 3. Data Completeness
- Required park information
- Ride specification standards
- Historical record requirements
- Media documentation needs
## Usage Flows
### 1. Park Discovery Flow
```
Search/Browse → Park Selection → Detail View → Related Content
```
### 2. Review Creation Flow
```
Experience → Media Upload → Review Draft → Submission → Moderation
```
### 3. Company Verification Flow
```
Registration → Documentation → Verification → Profile Access
```
### 4. Content Moderation Flow
```
Submission Queue → Review → Action → Notification
```
## Development Roadmap
### Current Phase
1. Core Platform
- Park/Ride management
- Review system
- Basic media handling
- User authentication
2. Quality Features
- Content moderation
- Company verification
- Expert system
- Media optimization
### Next Phase
1. Community Features
- Enhanced profiles
- Achievement system
- Social interactions
- Content collections
2. Advanced Media
- Video support
- Virtual tours
- 360° views
- AR capabilities
3. Analytics Enhancement
- Advanced metrics
- Personalization
- Trend prediction
- Quality scoring
## Integration Requirements
### External Systems
- Email service integration
- Social authentication providers
- Geographic data services
- Media processing services
### Internal Systems
- WebSocket notifications
- Background task processing
- Media optimization pipeline
- Analytics processing system
## Compliance Requirements
### Data Protection
- User privacy controls
- Data retention policies
- Export capabilities
- Deletion workflows
### Accessibility
- WCAG compliance
- Screen reader support
- Keyboard navigation
- Color contrast requirements
### Content Policies
- Review guidelines
- Media usage rights
- Attribution requirements
- Moderation standards

View File

@@ -0,0 +1,306 @@
# Issues and Technical Debt Documentation
## Known Bugs
### 1. Data Integrity Issues
#### Historical Slug Resolution
```python
# Current Implementation
class Park(models.Model):
@classmethod
def get_by_slug(cls, slug: str):
# Issue: Race condition possible between slug check and retrieval
# TODO: Implement proper locking or transaction handling
try:
return cls.objects.get(slug=slug)
except cls.DoesNotExist:
return cls.objects.get(historical_slugs__slug=slug)
```
#### Media File Management
```python
# Current Issue
class MediaHandler:
def process_upload(self, file):
# Bug: Temporary files not always cleaned up
# TODO: Implement proper cleanup in finally block
try:
process_file(file)
except Exception:
log_error()
```
### 2. Performance Issues
#### N+1 Query Patterns
```python
# Inefficient Queries in Views
class ParkDetailView(DetailView):
def get_context_data(self):
context = super().get_context_data()
# Issue: N+1 queries for each ride's reviews
context['rides'] = [
{
'ride': ride,
'reviews': ride.reviews.all() # Causes N+1 query
}
for ride in self.object.rides.all()
]
```
#### Cache Invalidation
```python
# Inconsistent Cache Updates
class ReviewManager:
def update_stats(self, obj):
# Bug: Race condition in cache updates
# TODO: Implement atomic cache updates
stats = calculate_stats(obj)
cache.set(f'{obj}_stats', stats)
```
## Technical Debt
### 1. Code Organization
#### Monolithic Views
```python
# views.py
class ParkView(View):
def post(self, request, *args, **kwargs):
# TODO: Break down into smaller, focused views
# Currently handles too many responsibilities:
# - Park creation
# - Media processing
# - Notification sending
# - Stats updating
```
#### Duplicate Business Logic
```python
# Multiple implementations of similar functionality
class ParkValidator:
def validate_status(self):
# TODO: Consolidate with RideValidator.validate_status
if self.status not in VALID_STATUSES:
raise ValidationError()
class RideValidator:
def validate_status(self):
if self.status not in VALID_STATUSES:
raise ValidationError()
```
### 2. Infrastructure
#### Configuration Management
```python
# settings.py
# TODO: Move to environment variables
DATABASE_PASSWORD = 'hardcoded_password'
API_KEY = 'hardcoded_key'
# TODO: Implement proper configuration management
FEATURE_FLAGS = {
'new_review_system': True,
'beta_features': False
}
```
#### Deployment Process
```bash
# Manual deployment steps
# TODO: Automate deployment process
ssh server
git pull
pip install -r requirements.txt
python manage.py migrate
supervisorctl restart app
```
### 3. Testing
#### Test Coverage Gaps
```python
# Missing test cases for error conditions
class ParkTests(TestCase):
def test_create_park(self):
# Only tests happy path
park = Park.objects.create(name='Test Park')
self.assertEqual(park.name, 'Test Park')
# TODO: Add tests for:
# - Invalid input handling
# - Concurrent modifications
# - Edge cases
```
#### Integration Test Debt
```python
# Brittle integration tests
class APITests(TestCase):
# TODO: Replace with proper test doubles
def setUp(self):
# Direct database dependencies
self.park = Park.objects.create()
# External service calls
self.geocoder = RealGeocoder()
```
## Enhancement Opportunities
### 1. Feature Enhancements
#### Advanced Search
```python
# Current basic search implementation
class ParkSearch:
def search(self, query):
# TODO: Implement advanced search features:
# - Full-text search
# - Faceted search
# - Geographic search
return Park.objects.filter(name__icontains=query)
```
#### Review System
```python
# Basic review functionality
class Review(models.Model):
# TODO: Enhance with:
# - Rich text support
# - Media attachments
# - Review responses
# - Helpful votes
rating = models.IntegerField()
comment = models.TextField()
```
### 2. Technical Improvements
#### API Versioning
```python
# Current API structure
# TODO: Implement proper API versioning
urlpatterns = [
path('api/parks/', ParkViewSet.as_view()),
# Need to support:
# - Multiple versions
# - Deprecation handling
# - Documentation
]
```
#### Caching Strategy
```python
# Basic caching
# TODO: Implement:
# - Multi-layer caching
# - Cache warming
# - Intelligent invalidation
@cache_page(60 * 15)
def park_detail(request, slug):
return render(request, 'park_detail.html')
```
### 3. Performance Optimizations
#### Database Optimization
```python
# Current database usage
# TODO: Implement:
# - Connection pooling
# - Read replicas
# - Query optimization
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': 'thrillwiki',
}
}
```
#### Asset Delivery
```python
# Static file handling
# TODO: Implement:
# - CDN integration
# - Image optimization pipeline
# - Responsive images
STATIC_URL = '/static/'
MEDIA_URL = '/media/'
```
## Prioritized Improvements
### High Priority
1. Security Fixes
- Fix authentication vulnerabilities
- Implement proper input validation
- Secure file uploads
2. Critical Performance Issues
- Resolve N+1 queries
- Implement connection pooling
- Optimize cache usage
3. Data Integrity
- Fix race conditions
- Implement proper transactions
- Add data validation
### Medium Priority
1. Technical Debt
- Refactor monolithic views
- Consolidate duplicate code
- Improve test coverage
2. Developer Experience
- Automate deployment
- Improve documentation
- Add development tools
3. Feature Enhancements
- Implement advanced search
- Enhance review system
- Add API versioning
### Low Priority
1. Nice-to-have Features
- Rich text support
- Enhanced media handling
- Social features
2. Infrastructure Improvements
- CDN integration
- Monitoring enhancements
- Analytics improvements
## Implementation Plan
### Phase 1: Critical Fixes
```python
# Timeline: Q1 2024
# Focus:
# - Security vulnerabilities
# - Performance bottlenecks
# - Data integrity issues
```
### Phase 2: Technical Debt
```python
# Timeline: Q2 2024
# Focus:
# - Code refactoring
# - Test coverage
# - Documentation
```
### Phase 3: Enhancements
```python
# Timeline: Q3-Q4 2024
# Focus:
# - Feature improvements
# - Infrastructure upgrades
# - User experience

View File

@@ -0,0 +1,388 @@
# Performance Documentation
## Performance Architecture
### Caching Strategy
#### Cache Layers
```python
CACHES = {
'default': {
'BACKEND': 'django.core.cache.backends.redis.RedisCache',
'LOCATION': 'redis://127.0.0.1:6379/1',
'OPTIONS': {
'CLIENT_CLASS': 'django_redis.client.DefaultClient',
'PARSER_CLASS': 'redis.connection.HiredisParser',
'CONNECTION_POOL_CLASS': 'redis.BlockingConnectionPool',
'CONNECTION_POOL_CLASS_KWARGS': {
'max_connections': 50,
'timeout': 20,
}
}
}
}
```
#### Cache Patterns
```python
# View caching
@method_decorator(cache_page(60 * 15))
def park_list(request):
parks = Park.objects.all()
return render(request, 'parks/list.html', {'parks': parks})
# Template fragment caching
{% load cache %}
{% cache 300 park_detail park.id %}
... expensive template logic ...
{% endcache %}
# Low-level cache API
def get_park_stats(park_id):
cache_key = f'park_stats:{park_id}'
stats = cache.get(cache_key)
if stats is None:
stats = calculate_park_stats(park_id)
cache.set(cache_key, stats, timeout=3600)
return stats
```
### Database Optimization
#### Query Optimization
```python
# Efficient querying patterns
class ParkQuerySet(models.QuerySet):
def with_stats(self):
return self.annotate(
ride_count=Count('rides'),
avg_rating=Avg('reviews__rating')
).select_related('owner')\
.prefetch_related('rides', 'areas')
# Indexes
class Park(models.Model):
class Meta:
indexes = [
models.Index(fields=['slug']),
models.Index(fields=['status', 'created_at']),
models.Index(fields=['location_id', 'status'])
]
```
#### Database Configuration
```python
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': 'thrillwiki',
'CONN_MAX_AGE': 60,
'OPTIONS': {
'statement_timeout': 3000,
'idle_in_transaction_timeout': 3000,
},
'ATOMIC_REQUESTS': False,
'CONN_HEALTH_CHECKS': True,
}
}
```
### Asset Optimization
#### Static File Handling
```python
# WhiteNoise configuration
STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage'
WHITENOISE_OPTIONS = {
'allow_all_origins': False,
'max_age': 31536000, # 1 year
'compression_enabled': True,
}
```
#### Media Optimization
```python
from PIL import Image
def optimize_image(image_path):
with Image.open(image_path) as img:
# Convert to WebP
webp_path = f"{os.path.splitext(image_path)[0]}.webp"
img.save(webp_path, 'WebP', quality=85, method=6)
# Create thumbnails
sizes = [(800, 600), (400, 300)]
for size in sizes:
thumb = img.copy()
thumb.thumbnail(size)
thumb_path = f"{os.path.splitext(image_path)[0]}_{size[0]}x{size[1]}.webp"
thumb.save(thumb_path, 'WebP', quality=85, method=6)
```
## Performance Monitoring
### Application Monitoring
#### APM Configuration
```python
MIDDLEWARE = [
'django_prometheus.middleware.PrometheusBeforeMiddleware',
# ... other middleware ...
'django_prometheus.middleware.PrometheusAfterMiddleware',
]
PROMETHEUS_METRICS = {
'scrape_interval': 15,
'namespace': 'thrillwiki',
'metrics_path': '/metrics',
}
```
#### Custom Metrics
```python
from prometheus_client import Counter, Histogram
# Request metrics
http_requests_total = Counter(
'http_requests_total',
'Total HTTP requests',
['method', 'endpoint', 'status']
)
# Response time metrics
response_time = Histogram(
'response_time_seconds',
'Response time in seconds',
['endpoint']
)
```
### Performance Logging
#### Logging Configuration
```python
LOGGING = {
'handlers': {
'performance': {
'level': 'INFO',
'class': 'logging.handlers.TimedRotatingFileHandler',
'filename': 'logs/performance.log',
'when': 'midnight',
'interval': 1,
'backupCount': 30,
}
},
'loggers': {
'performance': {
'handlers': ['performance'],
'level': 'INFO',
'propagate': False,
}
}
}
```
#### Performance Logging Middleware
```python
class PerformanceMiddleware:
def __init__(self, get_response):
self.get_response = get_response
self.logger = logging.getLogger('performance')
def __call__(self, request):
start_time = time.time()
response = self.get_response(request)
duration = time.time() - start_time
self.logger.info({
'path': request.path,
'method': request.method,
'duration': duration,
'status': response.status_code
})
return response
```
## Scaling Strategy
### Application Scaling
#### Asynchronous Tasks
```python
# Celery configuration
CELERY_BROKER_URL = 'redis://localhost:6379/2'
CELERY_RESULT_BACKEND = 'redis://localhost:6379/3'
CELERY_TASK_ROUTES = {
'media.tasks.process_image': {'queue': 'media'},
'analytics.tasks.update_stats': {'queue': 'analytics'},
}
# Task definition
@shared_task(rate_limit='100/m')
def process_image(image_id):
image = Image.objects.get(id=image_id)
optimize_image(image.file.path)
create_thumbnails(image)
```
#### Load Balancing
```nginx
# Nginx configuration
upstream thrillwiki {
least_conn; # Least connections algorithm
server backend1.thrillwiki.com:8000;
server backend2.thrillwiki.com:8000;
server backend3.thrillwiki.com:8000;
keepalive 32;
}
server {
listen 80;
server_name thrillwiki.com;
location / {
proxy_pass http://thrillwiki;
proxy_http_version 1.1;
proxy_set_header Connection "";
}
}
```
### Database Scaling
#### Read Replicas
```python
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': 'thrillwiki',
# Primary DB configuration
},
'replica1': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': 'thrillwiki',
# Read replica configuration
}
}
DATABASE_ROUTERS = ['core.db.PrimaryReplicaRouter']
```
#### Connection Pooling
```python
# Django DB configuration with PgBouncer
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'OPTIONS': {
'application_name': 'thrillwiki',
'max_prepared_transactions': 0,
},
'POOL_OPTIONS': {
'POOL_SIZE': 20,
'MAX_OVERFLOW': 10,
'RECYCLE': 300,
}
}
}
```
### Caching Strategy
#### Multi-layer Caching
```python
# Cache configuration with fallback
CACHES = {
'default': {
'BACKEND': 'django_redis.cache.RedisCache',
'LOCATION': 'redis://primary:6379/1',
'OPTIONS': {
'CLIENT_CLASS': 'django_redis.client.DefaultClient',
'MASTER_CACHE': True,
}
},
'replica': {
'BACKEND': 'django_redis.cache.RedisCache',
'LOCATION': 'redis://replica:6379/1',
'OPTIONS': {
'CLIENT_CLASS': 'django_redis.client.DefaultClient',
}
}
}
```
#### Cache Invalidation
```python
class CacheInvalidationMixin:
def save(self, *args, **kwargs):
# Invalidate related caches
cache_keys = self.get_cache_keys()
cache.delete_many(cache_keys)
super().save(*args, **kwargs)
def get_cache_keys(self):
# Return list of related cache keys
return [
f'park:{self.pk}',
f'park_stats:{self.pk}',
'park_list'
]
```
## Performance Bottlenecks
### Known Issues
1. N+1 Query Patterns
```python
# Bad pattern
for park in Park.objects.all():
print(park.rides.count()) # Causes N+1 queries
# Solution
parks = Park.objects.annotate(
ride_count=Count('rides')
).all()
```
2. Memory Leaks
```python
# Memory leak in long-running tasks
class LongRunningTask:
def __init__(self):
self.cache = {}
def process(self, items):
# Clear cache periodically
if len(self.cache) > 1000:
self.cache.clear()
```
### Performance Tips
1. Query Optimization
```python
# Use exists() for checking existence
if Park.objects.filter(slug=slug).exists():
# Do something
# Use values() for simple data
parks = Park.objects.values('id', 'name')
```
2. Bulk Operations
```python
# Use bulk create
Park.objects.bulk_create([
Park(name='Park 1'),
Park(name='Park 2')
])
# Use bulk update
Park.objects.filter(status='CLOSED').update(
status='OPERATING'
)

View File

@@ -0,0 +1,339 @@
# Security Documentation
## Authentication System
### Authentication Stack
```python
# Settings configuration
AUTHENTICATION_BACKENDS = [
'django.contrib.auth.backends.ModelBackend',
'allauth.account.auth_backends.AuthenticationBackend',
]
INSTALLED_APPS = [
'django.contrib.auth',
'django.contrib.sessions',
'allauth',
'allauth.account',
'allauth.socialaccount',
'oauth2_provider',
]
```
### Authentication Flow
```mermaid
sequenceDiagram
User->>+Server: Login Request
Server->>+Auth Service: Validate Credentials
Auth Service->>+Database: Check User
Database-->>-Auth Service: User Data
Auth Service-->>-Server: Auth Token
Server-->>-User: Session Cookie
```
## Authorization Framework
### Permission System
#### Model Permissions
```python
class Park(models.Model):
class Meta:
permissions = [
("can_publish_park", "Can publish park"),
("can_moderate_park", "Can moderate park"),
("can_verify_park", "Can verify park information"),
]
```
#### View Permissions
```python
class ModeratedCreateView(LoginRequiredMixin, PermissionRequiredMixin):
permission_required = 'parks.can_publish_park'
raise_exception = True
```
### Role-Based Access Control
#### User Groups
1. Administrators
- Full system access
- Configuration management
- User management
2. Moderators
- Content moderation
- User management
- Report handling
3. Company Representatives
- Company profile management
- Official updates
- Response management
4. Regular Users
- Content creation
- Review submission
- Media uploads
#### Permission Matrix
```python
ROLE_PERMISSIONS = {
'administrator': [
'can_manage_users',
'can_configure_system',
'can_moderate_content',
],
'moderator': [
'can_moderate_content',
'can_manage_reports',
'can_verify_information',
],
'company_rep': [
'can_manage_company',
'can_post_updates',
'can_respond_reviews',
],
'user': [
'can_create_content',
'can_submit_reviews',
'can_upload_media',
],
}
```
## Security Controls
### Request Security
#### CSRF Protection
```python
MIDDLEWARE = [
'django.middleware.csrf.CsrfViewMiddleware',
]
# Template configuration
{% csrf_token %}
# AJAX request handling
headers: {
'X-CSRFToken': getCookie('csrftoken')
}
```
#### XSS Prevention
```python
# Template autoescape
{% autoescape on %}
{{ user_content }}
{% endautoescape %}
# Content Security Policy
CSP_DEFAULT_SRC = ("'self'",)
CSP_SCRIPT_SRC = ("'self'",)
CSP_STYLE_SRC = ("'self'", "'unsafe-inline'")
CSP_IMG_SRC = ("'self'", "data:", "https:")
```
### Data Protection
#### Password Security
```python
# Password validation
AUTH_PASSWORD_VALIDATORS = [
{
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
'OPTIONS': {
'min_length': 12,
}
},
{
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
},
]
```
#### Data Encryption
```python
# Database encryption
ENCRYPTED_FIELDS = {
'fields': {
'users.User.ssn': 'django_cryptography.fields.encrypt',
'payment.Card.number': 'django_cryptography.fields.encrypt',
},
}
# File encryption
ENCRYPTED_FILE_STORAGE = 'django_cryptography.storage.EncryptedFileSystemStorage'
```
### Session Security
#### Session Configuration
```python
# Session settings
SESSION_COOKIE_SECURE = True
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = 'Lax'
SESSION_ENGINE = 'django.contrib.sessions.backends.cached_db'
SESSION_EXPIRE_AT_BROWSER_CLOSE = True
```
#### Session Management
```python
# Session cleanup
CELERYBEAT_SCHEDULE = {
'cleanup-expired-sessions': {
'task': 'core.tasks.cleanup_expired_sessions',
'schedule': crontab(hour=4, minute=0)
},
}
```
## API Security
### Authentication
```python
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': [
'rest_framework_jwt.authentication.JSONWebTokenAuthentication',
'rest_framework.authentication.SessionAuthentication',
],
'DEFAULT_PERMISSION_CLASSES': [
'rest_framework.permissions.IsAuthenticated',
],
}
```
### Rate Limiting
```python
# Rate limiting configuration
REST_FRAMEWORK = {
'DEFAULT_THROTTLE_CLASSES': [
'rest_framework.throttling.AnonRateThrottle',
'rest_framework.throttling.UserRateThrottle'
],
'DEFAULT_THROTTLE_RATES': {
'anon': '100/day',
'user': '1000/day'
}
}
```
## Security Headers
### HTTP Security Headers
```python
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
]
SECURE_HSTS_SECONDS = 31536000
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = True
SECURE_SSL_REDIRECT = True
SECURE_REFERRER_POLICY = 'same-origin'
SECURE_BROWSER_XSS_FILTER = True
```
## File Upload Security
### Upload Configuration
```python
# File upload settings
FILE_UPLOAD_MAX_MEMORY_SIZE = 2621440 # 2.5 MB
FILE_UPLOAD_PERMISSIONS = 0o644
ALLOWED_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif']
def validate_file_extension(value):
ext = os.path.splitext(value.name)[1]
if not ext.lower() in ALLOWED_EXTENSIONS:
raise ValidationError('Unsupported file extension.')
```
### Media Security
```python
# Serve media files securely
@login_required
def serve_protected_file(request, path):
if not request.user.has_perm('can_access_file'):
raise PermissionDenied
response = serve(request, path, document_root=settings.MEDIA_ROOT)
response['Content-Disposition'] = 'attachment'
return response
```
## Security Monitoring
### Audit Logging
```python
# Audit log configuration
AUDIT_LOG_HANDLERS = {
'security': {
'level': 'INFO',
'class': 'logging.handlers.RotatingFileHandler',
'filename': 'logs/security.log',
'maxBytes': 1024*1024*5, # 5 MB
'backupCount': 5,
},
}
# Audit log usage
def log_security_event(event_type, user, details):
logger.info(f'Security event: {event_type}', extra={
'user_id': user.id,
'ip_address': get_client_ip(request),
'details': details
})
```
### Security Alerts
```python
# Alert configuration
SECURITY_ALERTS = {
'login_attempts': {
'threshold': 5,
'window': 300, # 5 minutes
'action': 'account_lock'
},
'api_errors': {
'threshold': 100,
'window': 3600, # 1 hour
'action': 'notify_admin'
}
}
```
## Incident Response
### Security Incident Workflow
1. Detection
2. Analysis
3. Containment
4. Eradication
5. Recovery
6. Lessons Learned
### Response Actions
```python
class SecurityIncident:
def contain_threat(self):
# Lock affected accounts
# Block suspicious IPs
# Disable compromised tokens
def investigate(self):
# Collect logs
# Analyze patterns
# Document findings
def recover(self):
# Restore systems
# Reset credentials
# Update security controls

View File

@@ -0,0 +1,350 @@
# Testing Documentation
## Testing Architecture
### Test Organization
```
tests/
├── unit/
│ ├── test_models.py
│ ├── test_views.py
│ └── test_forms.py
├── integration/
│ ├── test_workflows.py
│ └── test_apis.py
└── e2e/
└── test_user_journeys.py
```
### Test Configuration
```python
# pytest configuration
pytest_plugins = [
"tests.fixtures.parks",
"tests.fixtures.users",
"tests.fixtures.media"
]
# Test settings
TEST_RUNNER = 'django.test.runner.DiscoverRunner'
TEST_MODE = True
```
## Test Types
### Unit Tests
#### Model Tests
```python
class ParkModelTest(TestCase):
def setUp(self):
self.park = Park.objects.create(
name="Test Park",
status="OPERATING"
)
def test_slug_generation(self):
self.assertEqual(self.park.slug, "test-park")
def test_status_validation(self):
with self.assertRaises(ValidationError):
Park.objects.create(
name="Invalid Park",
status="INVALID"
)
```
#### View Tests
```python
class ParkViewTest(TestCase):
def setUp(self):
self.client = Client()
self.user = User.objects.create_user(
username="testuser",
[PASSWORD-REMOVED]"
)
def test_park_list_view(self):
response = self.client.get(reverse('parks:list'))
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, 'parks/park_list.html')
```
#### Form Tests
```python
class RideFormTest(TestCase):
def test_valid_form(self):
form = RideForm({
'name': 'Test Ride',
'status': 'OPERATING',
'height_requirement': 48
})
self.assertTrue(form.is_valid())
```
### Integration Tests
#### Workflow Tests
```python
class ReviewWorkflowTest(TestCase):
def test_review_moderation_flow(self):
# Create review
review = self.create_review()
# Submit for moderation
response = self.client.post(
reverse('reviews:submit_moderation',
kwargs={'pk': review.pk})
)
self.assertEqual(review.refresh_from_db().status, 'PENDING')
# Approve review
moderator = self.create_moderator()
self.client.force_login(moderator)
response = self.client.post(
reverse('reviews:approve',
kwargs={'pk': review.pk})
)
self.assertEqual(review.refresh_from_db().status, 'APPROVED')
```
#### API Tests
```python
class ParkAPITest(APITestCase):
def test_park_list_api(self):
url = reverse('api:park-list')
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
def test_park_create_api(self):
url = reverse('api:park-create')
data = {
'name': 'New Park',
'status': 'OPERATING'
}
response = self.client.post(url, data, format='json')
self.assertEqual(response.status_code, 201)
```
### End-to-End Tests
#### User Journey Tests
```python
class UserJourneyTest(LiveServerTestCase):
def test_park_review_journey(self):
# User logs in
self.login_user()
# Navigate to park
self.browser.get(f'{self.live_server_url}/parks/test-park/')
# Create review
self.browser.find_element_by_id('write-review').click()
self.browser.find_element_by_id('review-text').send_keys('Great park!')
self.browser.find_element_by_id('submit').click()
# Verify review appears
review_element = self.browser.find_element_by_class_name('review-item')
self.assertIn('Great park!', review_element.text)
```
## CI/CD Pipeline
### GitHub Actions Configuration
```yaml
name: ThrillWiki CI
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main, develop ]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:13
env:
POSTGRES_PASSWORD: postgres
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps:
- uses: actions/checkout@v2
- name: Set up Python
uses: actions/setup-python@v2
with:
python-version: '3.11'
- name: Install Dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Run Tests
env:
DATABASE_URL: postgres://postgres:postgres@localhost:5432/thrillwiki_test
run: |
pytest --cov=./ --cov-report=xml
- name: Upload Coverage
uses: codecov/codecov-action@v1
```
## Quality Metrics
### Code Coverage
```python
# Coverage configuration
[coverage:run]
source = .
omit =
*/migrations/*
*/tests/*
manage.py
[coverage:report]
exclude_lines =
pragma: no cover
def __str__
raise NotImplementedError
```
### Code Quality Tools
```python
# flake8 configuration
[flake8]
max-line-length = 88
extend-ignore = E203
exclude = .git,__pycache__,build,dist
# black configuration
[tool.black]
line-length = 88
target-version = ['py311']
include = '\.pyi?$'
```
## Test Data Management
### Fixtures
```python
# fixtures/parks.json
[
{
"model": "parks.park",
"pk": 1,
"fields": {
"name": "Test Park",
"slug": "test-park",
"status": "OPERATING"
}
}
]
```
### Factory Classes
```python
from factory.django import DjangoModelFactory
class ParkFactory(DjangoModelFactory):
class Meta:
model = Park
name = factory.Sequence(lambda n: f'Test Park {n}')
status = 'OPERATING'
```
## Performance Testing
### Load Testing
```python
from locust import HttpUser, task, between
class ParkUser(HttpUser):
wait_time = between(1, 3)
@task
def view_park_list(self):
self.client.get("/parks/")
@task
def view_park_detail(self):
self.client.get("/parks/test-park/")
```
### Benchmark Tests
```python
class ParkBenchmarkTest(TestCase):
def test_park_list_performance(self):
start_time = time.time()
Park.objects.all().select_related('owner')
end_time = time.time()
self.assertLess(end_time - start_time, 0.1)
```
## Test Automation
### Test Runner Configuration
```python
# Custom test runner
class CustomTestRunner(DiscoverRunner):
def setup_databases(self, **kwargs):
# Custom database setup
return super().setup_databases(**kwargs)
def teardown_databases(self, old_config, **kwargs):
# Custom cleanup
return super().teardown_databases(old_config, **kwargs)
```
### Automated Test Execution
```bash
# Test execution script
#!/bin/bash
# Run unit tests
pytest tests/unit/
# Run integration tests
pytest tests/integration/
# Run e2e tests
pytest tests/e2e/
# Generate coverage report
coverage run -m pytest
coverage report
coverage html
```
## Monitoring and Reporting
### Test Reports
```python
# pytest-html configuration
pytest_html_report_title = "ThrillWiki Test Report"
def pytest_html_report_data(report):
report.description = "Test Results for ThrillWiki"
```
### Coverage Reports
```python
# Coverage reporting configuration
COVERAGE_REPORT_OPTIONS = {
'report_type': 'html',
'directory': 'coverage_html',
'title': 'ThrillWiki Coverage Report',
'show_contexts': True
}

View File

@@ -0,0 +1,63 @@
# Base Autocomplete Implementation
The project uses `django-htmx-autocomplete` with a custom base implementation to ensure consistent behavior across all autocomplete widgets.
## BaseAutocomplete Class
Located in `core/forms.py`, the `BaseAutocomplete` class provides project-wide defaults and standardization:
```python
from core.forms import BaseAutocomplete
class MyModelAutocomplete(BaseAutocomplete):
model = MyModel
search_attrs = ['name', 'description']
```
### Features
- **Authentication Enforcement**: Requires user authentication by default
- Controlled via `AUTOCOMPLETE_BLOCK_UNAUTHENTICATED` setting
- Override `auth_check()` for custom auth logic
- **Search Configuration**
- `minimum_search_length = 2` - More responsive than default 3
- `max_results = 10` - Optimized for performance
- **Internationalization**
- All text strings use Django's translation system
- Customizable messages through class attributes
### Usage Guidelines
1. Always extend `BaseAutocomplete` instead of using `autocomplete.Autocomplete` directly
2. Configure search_attrs based on your model's indexed fields
3. Use the AutocompleteWidget with proper options:
```python
class MyForm(forms.ModelForm):
class Meta:
model = MyModel
fields = ['related_field']
widgets = {
'related_field': AutocompleteWidget(
ac_class=MyModelAutocomplete,
options={
"multiselect": True, # For M2M fields
"placeholder": "Custom placeholder..." # Optional
}
)
}
```
### Performance Considerations
- Keep `search_attrs` minimal and indexed
- Use `select_related`/`prefetch_related` in custom querysets
- Consider caching for frequently used results
### Security Notes
- Authentication required by default
- Implements proper CSRF protection via HTMX
- Rate limiting should be implemented at the web server level

View File

@@ -0,0 +1,165 @@
# Frontend Structure Documentation
## Project Organization
```
frontend/
├── src/
│ ├── app/ # Next.js App Router pages
│ ├── components/ # Reusable React components
│ ├── types/ # TypeScript type definitions
│ └── lib/ # Utility functions and configurations
```
## Component Architecture
### Core Components
1. **Layout (layout.tsx)**
- Global page structure
- Navigation header
- Footer
- Consistent styling across pages
- Using Inter font
- Responsive design with Tailwind
2. **Error Boundary (error-boundary.tsx)**
- Global error handling
- Fallback UI for errors
- Error reporting capabilities
- Retry functionality
- Type-safe implementation
3. **Home Page (page.tsx)**
- Parks listing
- Loading states
- Error handling
- Responsive grid layout
- Pagination support
## API Integration
### Parks API
- GET /api/parks
- Pagination support
- Search functionality
- Error handling
- Type-safe responses
## State Management
- Using React hooks for local state
- Server-side data fetching
- Error state handling
- Loading state management
## Styling Approach
1. **Tailwind CSS**
- Utility-first approach
- Custom theme configuration
- Responsive design utilities
- Component-specific styles
2. **Component Design**
- Consistent spacing
- Mobile-first approach
- Accessible color schemes
- Interactive states
## Type Safety
1. **TypeScript Integration**
- Strict type checking
- API response types
- Component props typing
- Error handling types
## Error Handling Strategy
1. **Multiple Layers**
- Component-level error boundaries
- API error handling
- Type-safe error responses
- User-friendly error messages
2. **Recovery Options**
- Retry functionality
- Graceful degradation
- Clear error messaging
- User guidance
## Performance Considerations
1. **Optimizations**
- Component code splitting
- Image optimization
- Responsive loading
- Caching strategy
2. **Monitoring**
- Error tracking
- Performance metrics
- User interactions
- API response times
## Accessibility
1. **Implementation**
- ARIA labels
- Keyboard navigation
- Focus management
- Screen reader support
## Next Steps
### Immediate Tasks
1. Implement authentication components
2. Add park detail page
3. Implement search functionality
4. Add pagination controls
### Future Improvements
1. Add park reviews system
2. Implement user profiles
3. Add social sharing
4. Enhance search capabilities
## Migration Progress
### Completed
✅ Basic page structure
✅ Error handling system
✅ Parks listing page
✅ API integration structure
### In Progress
⏳ Authentication system
⏳ Park details page
⏳ Search functionality
### Pending
⬜ User profiles
⬜ Reviews system
⬜ Admin interface
⬜ Analytics integration
## Testing Strategy
### Unit Tests
- Component rendering
- API integrations
- Error handling
- State management
### Integration Tests
- User flows
- API interactions
- Error scenarios
- Authentication
### E2E Tests
- Critical paths
- User journeys
- Cross-browser testing
- Mobile responsiveness
## Documentation Needs
1. Component API documentation
2. State management patterns
3. Error handling procedures
4. Testing guidelines

View File

@@ -0,0 +1,155 @@
# Next.js Migration Progress
## Current Status (Updated 2/23/2025)
### Completed Setup
1. ✅ Next.js project initialized in frontend/
2. ✅ TypeScript configuration
3. ✅ Prisma setup with PostGIS support
4. ✅ Environment configuration
5. ✅ API route structure
6. ✅ Initial database schema sync
7. ✅ Basic UI components
### Database Migration Status
- Detected existing Django schema with 70+ tables
- Successfully initialized Prisma with PostGIS extension
- Created initial migration
### Key Database Tables Identified
1. Core Tables
- accounts_user
- parks_park
- reviews_review
- location_location
- media_photo
2. Authentication Tables
- socialaccount_socialaccount
- token_blacklist_blacklistedtoken
- auth_permission
3. Content Management
- wiki_article
- wiki_articlerevision
- core_slughistory
### Implemented Features
1. Authentication Middleware
- Basic JWT token validation
- Public/private route handling
- Token forwarding
2. API Types System
- Base response types
- Park types
- User types
- Review types
- Error handling types
3. Database Connection
- Prisma client setup
- PostGIS extension configuration
- Development/production handling
4. Parks API Route
- GET endpoint with pagination
- Search functionality
- POST endpoint with auth
- Error handling
## Next Steps (Prioritized)
### 1. Schema Migration (Current Focus)
- [ ] Map remaining Django models to Prisma schema
- [ ] Handle custom field types (e.g., GeoDjango fields)
- [ ] Set up relationships between models
- [ ] Create data migration scripts
### 2. Authentication System
- [ ] Implement JWT verification
- [ ] Set up refresh tokens
- [ ] Social auth integration
- [ ] User session management
### 3. Core Features Migration
- [ ] Parks system
- [ ] User profiles
- [ ] Review system
- [ ] Media handling
### 4. Testing & Validation
- [ ] Unit tests for API routes
- [ ] Integration tests
- [ ] Data integrity checks
- [ ] Performance testing
## Technical Decisions
### Schema Migration Strategy
- Incremental model migration
- Maintain foreign key relationships
- Handle custom field types via Prisma
- Use PostGIS for spatial data
### Authentication Approach
- JWT for API authentication
- HTTP-only cookies for token storage
- Refresh token rotation
- Social auth provider integration
### API Architecture
- REST-based endpoints
- Strong type safety
- Consistent response formats
- Built-in pagination
- Error handling middleware
### Component Architecture
- Server components by default
- Client components for interactivity
- Shared component library
- Error boundaries
## Migration Challenges
### Current Challenges
1. Complex Django model relationships
2. Custom field type handling
3. Social authentication flow
4. File upload system
5. Real-time feature migration
### Solutions
1. Using Prisma's preview features for PostGIS
2. Custom field type mappings
3. JWT-based auth with refresh tokens
4. S3/cloud storage integration
5. WebSocket/Server-Sent Events
## Monitoring & Validation
### Data Integrity
- Validation scripts for migrated data
- Comparison tools for Django/Prisma models
- Automated testing of relationships
- Error logging and monitoring
### Performance
- API response time tracking
- Database query optimization
- Client-side performance metrics
- Error rate monitoring
## Documentation Updates
1. API route specifications
2. Schema migration process
3. Authentication flows
4. Component documentation
5. Deployment guides
## Rollback Strategy
1. Maintain Django application
2. Database backups before migrations
3. Feature flags for gradual rollout
4. Monitoring thresholds for auto-rollback

View File

@@ -0,0 +1,25 @@
# Park Detail API Implementation
## Overview
Implementing the park detail API endpoint at `/api/parks/[slug]` to provide detailed information about a specific park.
## Implementation Details
- Route: `/api/parks/[slug]/route.ts`
- Response Type: `ParkDetailResponse` (already defined in api.ts)
- Error Handling:
- 404 for invalid park slugs
- 500 for server/database errors
## Data Structure
Reusing existing Park type with full relationships:
- Basic park info (name, description, etc.)
- Areas
- Reviews with user info
- Photos with user info
- Creator details
- Owner (company) details
## Date Formatting
Following established pattern:
- Split ISO dates at 'T' for date-only fields
- Full ISO string for timestamps

View File

@@ -0,0 +1,44 @@
# Park Detail Page Implementation
## Overview
Implemented the park detail page component with features for displaying comprehensive park information.
## Components
1. `/parks/[slug]/page.tsx`
- Dynamic metadata generation
- Server-side data fetching with caching
- Complete park information display
- Responsive layout with Tailwind CSS
- Error handling with notFound()
2. `/parks/[slug]/loading.tsx`
- Skeleton loading state
- Matches final layout structure
- Animated pulse effect
- Responsive grid matching page layout
## Features
- Park header with name and status badge
- Description section
- Key details grid (size, rides, ratings)
- Location display
- Areas list with descriptions
- Reviews section with ratings
- Photo gallery
- Dynamic metadata
- Error handling
- Loading states
## Data Handling
- 60-second cache for data fetching
- Error states for 404 and other failures
- Proper type safety with ParkDetailResponse
- Formatted dates for consistency
## Design Patterns
- Semantic HTML structure
- Consistent spacing and typography
- Responsive grid layouts
- Color-coded status badges
- Progressive loading with suspense
- Modular section organization

View File

@@ -0,0 +1,130 @@
# Park Search Implementation
## Search Flow
1. **Quick Search (Suggestions)**
- Endpoint: `suggest_parks/`
- Shows up to 8 suggestions
- Uses HTMX for real-time updates
- 300ms debounce for typing
2. **Full Search**
- Endpoint: `parks:park_list`
- Shows all matching results
- Supports view modes (grid/list)
- Integrates with filter system
## Implementation Details
### Frontend Components
- Search input using built-in HTMX and Alpine.js
```html
<div x-data="{ query: '', selectedId: null }"
@search-selected.window="...">
<form hx-get="..." hx-trigger="input changed delay:300ms">
<!-- Search input and UI components -->
</form>
</div>
```
- 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"
}
]
}
```
### Field Details
- `id`: Database ID (string format)
- `name`: Park name
- `status`: Formatted status display (e.g., "Operating")
- `location`: Formatted location string
- `url`: Full detail page URL
## Test Coverage
### API Tests
- JSON format validation
- Empty search handling
- Field type checking
- Result limit verification
- Response structure
### UI Integration Tests
- View mode persistence
- Loading state verification
- Error handling
- Keyboard interaction
### Data Format Tests
- Location string formatting
- Status display formatting
- URL generation
- Field type validation
### Performance Tests
- Debounce functionality
- Result limiting (8 items)
- Query optimization
- Response timing

View File

@@ -0,0 +1,128 @@
# Parks Page Next.js Implementation
## Troubleshooting Database Issues
### Database Setup and Maintenance
1. Created Database Reset Script
- Location: `frontend/src/scripts/db-reset.ts`
- Purpose: Clean database reset and reseed
- Features:
- Drops tables in correct order
- Runs seed script automatically
- Handles errors gracefully
- Usage: `npm run db:reset`
2. Package.json Scripts
```json
{
"scripts": {
"db:reset": "ts-node src/scripts/db-reset.ts",
"db:seed": "ts-node prisma/seed.ts",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate deploy"
}
}
```
3. Database Validation Steps
- Added connection test in API endpoint
- Added table existence check
- Enhanced error logging
- Added Prisma Client event listeners
### API Endpoint Improvements
1. Error Handling
```typescript
// Raw query test to verify database connection
const rawResult = await prisma.$queryRaw`SELECT tablename FROM pg_catalog.pg_tables WHERE schemaname = 'public'`;
// Transaction usage for atomicity
const queryResult = await prisma.$transaction(async (tx) => {
const totalCount = await tx.park.count();
const parks = await tx.park.findMany({...});
return { totalCount, parks };
});
```
2. Simplified Query Structure
- Reduced complexity for debugging
- Added basic fields first
- Added proper type checking
- Enhanced error details
3. Debug Logging
- Added connection test logs
- Added query execution logs
- Enhanced error object logging
### Test Data Management
1. Seed Data Structure
- 2 users (admin and test user)
- 2 companies (Universal and Cedar Fair)
- 2 parks with full details
- Test reviews for each park
2. Data Types
- Location stored as JSON
- Dates properly formatted
- Numeric fields with correct precision
- Relationships properly established
### Current Status
✅ Completed:
- Database reset script
- Enhanced error handling
- Debug logging
- Test data setup
- API endpoint improvements
🚧 Next Steps:
1. Run database reset and verify data
2. Test API endpoint with fresh data
3. Verify frontend component rendering
4. Add error boundaries for component-level errors
### Debugging Commands
```bash
# Reset and reseed database
npm run db:reset
# Generate Prisma client
npm run prisma:generate
# Deploy migrations
npm run prisma:migrate
```
### API Endpoint Response Format
```typescript
{
success: boolean;
data?: Park[];
meta?: {
total: number;
};
error?: string;
}
```
## Technical Decisions
1. Using transactions for queries to ensure data consistency
2. Added raw query test to validate database connection
3. Enhanced error handling with specific error types
4. Added debug logging for development troubleshooting
5. Simplified query structure for easier debugging
## Next Actions
1. Run `npm run db:reset` to clean and reseed database
2. Test simplified API endpoint
3. Gradually add back filters once basic query works
4. Add error boundaries to React components

View File

@@ -0,0 +1,119 @@
# Search Functionality Improvement Plan
## Technical Implementation Details
### 1. Database Optimization
```python
# parks/models.py
from django.contrib.postgres.indexes import GinIndex
class Park(models.Model):
class Meta:
indexes = [
GinIndex(fields=['name', 'description'],
name='search_gin_idx',
opclasses=['gin_trgm_ops', 'gin_trgm_ops']),
Index(fields=['location__address_text'], name='location_addr_idx')
]
# search/services.py
from django.db.models import F, Func
from analytics.models import SearchMetric
class SearchEngine:
@classmethod
def execute_search(cls, request, filterset_class):
with timeit() as timer:
filterset = filterset_class(request.GET, queryset=cls.base_queryset())
qs = filterset.qs
results = qs.annotate(
search_rank=Func(F('name'), F('description'),
function='ts_rank')
).order_by('-search_rank')
SearchMetric.record(
query_params=dict(request.GET),
result_count=qs.count(),
duration=timer.elapsed
)
return results
```
### 2. Architectural Changes
```python
# search/filters.py (simplified explicit filter)
class ParkFilter(SearchableFilterMixin, django_filters.FilterSet):
search_fields = ['name', 'description', 'location__address_text']
class Meta:
model = Park
fields = {
'ride_count': ['gte', 'lte'],
'coaster_count': ['gte', 'lte'],
'average_rating': ['gte', 'lte']
}
# search/views.py (updated)
class AdaptiveSearchView(TemplateView):
def get_queryset(self):
return SearchEngine.base_queryset()
def get_filterset(self):
return ParkFilter(self.request.GET, queryset=self.get_queryset())
```
### 3. Frontend Enhancements
```javascript
// static/js/search.js
const searchInput = document.getElementById('search-input');
let timeoutId;
searchInput.addEventListener('input', () => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
fetchResults(searchInput.value);
}, 300);
});
async function fetchResults(query) {
try {
const response = await fetch(`/search/?search=${encodeURIComponent(query)}`);
if (!response.ok) throw new Error(response.statusText);
const html = await response.text();
updateResults(html);
} catch (error) {
showError(`Search failed: ${error.message}`);
}
}
```
## Implementation Roadmap
1. Database Migrations
```bash
uv run manage.py makemigrations parks --name add_search_indexes
uv run manage.py migrate
```
2. Service Layer Integration
- Create search/services.py with query instrumentation
- Update all views to use SearchEngine class
3. Frontend Updates
- Add debouncing to search inputs
- Implement error handling UI components
- Add loading spinner component
4. Monitoring Setup
```python
# analytics/models.py
class SearchMetric(models.Model):
query_params = models.JSONField()
result_count = models.IntegerField()
duration = models.FloatField()
created_at = models.DateTimeField(auto_now_add=True)
```
5. Performance Testing
- Use django-debug-toolbar for query analysis
- Generate load tests with locust.io

View File

@@ -161,6 +161,22 @@ class ViewTests(TestCase):
## Development Workflows ## Development Workflows
### Package Management
IMPORTANT: When adding Python packages to the project, only use UV:
```bash
uv add <package>
```
Do not attempt to install packages using any other method (pip, poetry, etc.).
### Development Server Management
Server Startup Process
IMPORTANT: Always execute the following command exactly as shown to start the development server:
```bash
lsof -ti :8000 | xargs kill -9; find . -type d -name "__pycache__" -exec rm -r {} +; uv run manage.py tailwind runserver
```
Note: These steps must be executed in this exact order as a single command to ensure consistent behavior.
### Feature Development ### Feature Development
1. Planning 1. Planning
- Technical specification - Technical specification

View File

@@ -0,0 +1,123 @@
# Generated by Django 5.1.4 on 2025-02-21 17:55
import pgtrigger.compiler
import pgtrigger.migrations
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("moderation", "0001_initial"),
]
operations = [
pgtrigger.migrations.RemoveTrigger(
model_name="editsubmission",
name="insert_insert",
),
pgtrigger.migrations.RemoveTrigger(
model_name="editsubmission",
name="update_update",
),
pgtrigger.migrations.RemoveTrigger(
model_name="photosubmission",
name="insert_insert",
),
pgtrigger.migrations.RemoveTrigger(
model_name="photosubmission",
name="update_update",
),
migrations.AddField(
model_name="editsubmission",
name="updated_at",
field=models.DateTimeField(auto_now=True),
),
migrations.AddField(
model_name="editsubmissionevent",
name="updated_at",
field=models.DateTimeField(auto_now=True),
),
migrations.AddField(
model_name="photosubmission",
name="updated_at",
field=models.DateTimeField(auto_now=True),
),
migrations.AddField(
model_name="photosubmissionevent",
name="updated_at",
field=models.DateTimeField(auto_now=True),
),
migrations.AlterField(
model_name="editsubmission",
name="id",
field=models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
migrations.AlterField(
model_name="photosubmission",
name="id",
field=models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
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="[AWS-SECRET-REMOVED]",
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="[AWS-SECRET-REMOVED]",
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="[AWS-SECRET-REMOVED]",
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="[AWS-SECRET-REMOVED]",
operation="UPDATE",
pgid="pgtrigger_update_update_9c311",
table="moderation_photosubmission",
when="AFTER",
),
),
),
]

View File

@@ -12,7 +12,7 @@ from django_filters import (
BooleanFilter BooleanFilter
) )
from .models import Park from .models import Park
from .views import get_base_park_queryset from .querysets import get_base_park_queryset
from companies.models import Company from companies.models import Company
def validate_positive_integer(value): def validate_positive_integer(value):
@@ -31,44 +31,66 @@ class ParkFilter(LocationFilterMixin, RatingFilterMixin, DateRangeFilterMixin, F
model = Park model = Park
fields = [] fields = []
# Search field # Search field with better description
search = CharFilter(method='filter_search') search = CharFilter(
method='filter_search',
label=_("Search Parks"),
help_text=_("Search by park name, description, or location")
)
# Status filter # Status filter with clearer label
status = ChoiceFilter( status = ChoiceFilter(
field_name='status', field_name='status',
choices=Park._meta.get_field('status').choices, choices=Park._meta.get_field('status').choices,
empty_label='Any status' empty_label=_('Any status'),
label=_("Operating Status"),
help_text=_("Filter parks by their current operating status")
) )
# Owner filters # Owner filters with helpful descriptions
owner = ModelChoiceFilter( owner = ModelChoiceFilter(
field_name='owner', field_name='owner',
queryset=Company.objects.all(), queryset=Company.objects.all(),
empty_label='Any company' empty_label=_('Any company'),
label=_("Operating Company"),
help_text=_("Filter parks by their operating company")
)
has_owner = BooleanFilter(
method='filter_has_owner',
label=_("Company Status"),
help_text=_("Show parks with or without an operating company")
) )
has_owner = BooleanFilter(method='filter_has_owner')
# Numeric filters # Ride and attraction filters
min_rides = NumberFilter( min_rides = NumberFilter(
field_name='current_ride_count', field_name='current_ride_count',
lookup_expr='gte', lookup_expr='gte',
validators=[validate_positive_integer] validators=[validate_positive_integer],
label=_("Minimum Rides"),
help_text=_("Show parks with at least this many rides")
) )
min_coasters = NumberFilter( min_coasters = NumberFilter(
field_name='current_coaster_count', field_name='current_coaster_count',
lookup_expr='gte', lookup_expr='gte',
validators=[validate_positive_integer] validators=[validate_positive_integer],
label=_("Minimum Roller Coasters"),
help_text=_("Show parks with at least this many roller coasters")
) )
# Size filter
min_size = NumberFilter( min_size = NumberFilter(
field_name='size_acres', field_name='size_acres',
lookup_expr='gte', lookup_expr='gte',
validators=[validate_positive_integer] validators=[validate_positive_integer],
label=_("Minimum Size (acres)"),
help_text=_("Show parks of at least this size in acres")
) )
# Date filter # Opening date filter with better label
opening_date = DateFromToRangeFilter( opening_date = DateFromToRangeFilter(
field_name='opening_date' field_name='opening_date',
label=_("Opening Date Range"),
help_text=_("Filter parks by their opening date")
) )
def filter_search(self, queryset, name, value): def filter_search(self, queryset, name, value):
@@ -94,6 +116,7 @@ class ParkFilter(LocationFilterMixin, RatingFilterMixin, DateRangeFilterMixin, F
def filter_has_owner(self, queryset, name, value): def filter_has_owner(self, queryset, name, value):
"""Filter parks based on whether they have an owner""" """Filter parks based on whether they have an owner"""
return queryset.filter(owner__isnull=not value) return queryset.filter(owner__isnull=not value)
@property @property
def qs(self): def qs(self):
"""Override qs property to ensure we always use base queryset with annotations""" """Override qs property to ensure we always use base queryset with annotations"""

View File

@@ -1,7 +1,54 @@
from django import forms from django import forms
from decimal import Decimal, InvalidOperation, ROUND_DOWN from decimal import Decimal, InvalidOperation, ROUND_DOWN
from autocomplete import AutocompleteWidget
from core.forms import BaseAutocomplete
from .models import Park from .models import Park
from location.models import Location from location.models import Location
from .querysets import get_base_park_queryset
class ParkAutocomplete(BaseAutocomplete):
"""Autocomplete for searching parks.
Features:
- Name-based search with partial matching
- Prefetches related owner data
- Applies standard park queryset filtering
- Includes park status and location in results
"""
model = Park
search_attrs = ['name'] # We'll match on park names
def get_search_results(self, search):
"""Return search results with related data."""
return (get_base_park_queryset()
.filter(name__icontains=search)
.select_related('owner')
.order_by('name'))
def format_result(self, park):
"""Format each park result with status and location."""
location = park.formatted_location
location_text = f"{location}" if location else ""
return {
'key': str(park.pk),
'label': park.name,
'extra': f"{park.get_status_display()}{location_text}"
}
class ParkSearchForm(forms.Form):
"""Form for searching parks with autocomplete."""
park = forms.ModelChoiceField(
queryset=Park.objects.all(),
required=False,
widget=AutocompleteWidget(
ac_class=ParkAutocomplete,
attrs={'class': 'w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white',
'placeholder': 'Search parks...'}
)
)
class ParkForm(forms.ModelForm): class ParkForm(forms.ModelForm):

View File

@@ -0,0 +1,39 @@
# Generated by Django 5.1.4 on 2025-02-21 17:55
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("parks", "0002_fix_pghistory_fields"),
]
operations = [
migrations.AlterField(
model_name="park",
name="id",
field=models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
migrations.AlterField(
model_name="parkarea",
name="id",
field=models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
migrations.AlterField(
model_name="parkareaevent",
name="park",
field=models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="parks.park",
),
),
]

22
parks/querysets.py Normal file
View File

@@ -0,0 +1,22 @@
from django.db.models import QuerySet, Count, Q
from .models import Park
def get_base_park_queryset() -> QuerySet[Park]:
"""Get base queryset with all needed annotations and prefetches"""
from django.contrib.contenttypes.models import ContentType
park_type = ContentType.objects.get_for_model(Park)
return (
Park.objects.select_related('owner')
.prefetch_related(
'photos',
'rides',
'location',
'location__content_type'
)
.annotate(
current_ride_count=Count('rides', distinct=True),
current_coaster_count=Count('rides', filter=Q(rides__category="RC"), distinct=True)
)
.order_by('name')
)

View File

@@ -9,7 +9,8 @@
<div class="flex items-center space-x-4"> <div class="flex items-center space-x-4">
<h1 class="text-2xl font-bold text-gray-900">Parks</h1> <h1 class="text-2xl font-bold text-gray-900">Parks</h1>
<div class="flex items-center space-x-2 bg-gray-100 rounded-lg p-1" role="group" aria-label="View mode selection"> <fieldset class="flex items-center space-x-2 bg-gray-100 rounded-lg p-1">
<legend class="sr-only">View mode selection</legend>
<button hx-get="{% url 'parks:park_list' %}?view_mode=grid{% if request.GET.search %}&search={{ request.GET.search }}{% endif %}" <button hx-get="{% url 'parks:park_list' %}?view_mode=grid{% if request.GET.search %}&search={{ request.GET.search }}{% endif %}"
hx-target="#park-results" hx-target="#park-results"
hx-push-url="true" hx-push-url="true"
@@ -46,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">
<label for="search" class="sr-only">Search parks</label> <div class="w-full relative"
x-data="{ query: '', selectedId: null }"
@search-selected.window="
query = $event.detail;
selectedId = $event.target.value;
$refs.filterForm.querySelector('input[name=search]').value = query;
$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" <input type="search"
name="search" name="search"
id="search" placeholder="Search parks..."
class="block w-full rounded-md border-gray-300 bg-white py-3 pl-4 pr-10 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 sm:text-sm" class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="Search parks by name or location..." aria-label="Search parks"
hx-get="{% url 'parks:search_parks' %}" aria-controls="search-results"
hx-trigger="input delay:300ms, search" :aria-expanded="query !== ''"
hx-target="#park-results" x-model="query"
hx-push-url="true" @keydown.escape="query = ''">
hx-indicator="#search-indicator"
value="{{ request.GET.search|default:'' }}" <!-- Loading indicator -->
aria-label="Search parks"> <div id="search-indicator"
<div class="absolute inset-y-0 right-0 flex items-center pr-3"> class="htmx-indicator absolute right-3 top-1/2 -translate-y-1/2"
<div id="search-indicator" class="htmx-indicator"> role="status"
<svg class="h-5 w-5 text-gray-400 animate-spin" viewBox="0 0 24 24" aria-hidden="true"> 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"/> <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"/> <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
</svg> </svg>
<span class="sr-only">Searching...</span>
</div>
</div>
</form>
<div id="search-results"
class="absolute z-50 mt-1 w-full bg-white dark:bg-gray-800 rounded-md shadow-lg"
role="listbox">
<!-- Search suggestions will be loaded here -->
</div> </div>
</div> </div>
</div> </div>
@@ -73,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>
@@ -92,7 +119,3 @@
{% include "parks/partials/park_list_item.html" with parks=parks view_mode=view_mode|default:'grid' %} {% include "parks/partials/park_list_item.html" with parks=parks view_mode=view_mode|default:'grid' %}
</div> </div>
{% endblock %} {% endblock %}
{% block extra_js %}
<script src="{% static 'parks/js/search.js' %}"></script>
{% endblock %}

View File

@@ -11,80 +11,33 @@
</div> </div>
</div> </div>
{% else %} {% else %}
<div class="{% if view_mode == 'grid' %}grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 p-4{% else %}flex flex-col gap-4 p-4{% endif %}" <div class="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
data-testid="park-list"
data-view-mode="{{ view_mode|default:'grid' }}">
{% for park in object_list|default:parks %} {% for park in object_list|default:parks %}
<article class="park-card group relative bg-white border rounded-lg transition-all duration-200 ease-in-out hover:shadow-lg {% if view_mode == 'list' %}flex gap-4 p-4{% endif %}" <div class="overflow-hidden transition-transform transform bg-white rounded-lg shadow-lg dark:bg-gray-800 hover:-translate-y-1">
data-testid="park-card" <div class="p-4">
data-park-id="{{ park.id }}" <h2 class="mb-2 text-xl font-bold">
data-view-mode="{{ view_mode|default:'grid' }}"> <a href="{% url 'parks:park_detail' park.slug %}" class="text-gray-900 hover:text-blue-600 dark:text-white dark:hover:text-blue-400">
<a href="{% url 'parks:park_detail' park.slug %}"
class="absolute inset-0 z-0"
aria-label="View details for {{ park.name }}"></a>
<div class="relative z-10 {% if view_mode == 'grid' %}aspect-video{% endif %}">
{% if park.photos.exists %}
<img src="{{ park.photos.first.image.url }}"
alt="Photo of {{ park.name }}"
class="{% if view_mode == 'grid' %}w-full h-full object-cover rounded-t-lg{% else %}w-24 h-24 object-cover rounded-lg flex-shrink-0{% endif %}"
loading="lazy">
{% else %}
<div class="{% if view_mode == 'grid' %}w-full h-full bg-gray-100 rounded-t-lg flex items-center justify-center{% else %}w-24 h-24 bg-gray-100 rounded-lg flex-shrink-0 flex items-center justify-center{% endif %}"
role="img"
aria-label="Park initial letter">
<span class="text-2xl font-medium text-gray-400">{{ park.name|first|upper }}</span>
</div>
{% endif %}
</div>
<div class="{% if view_mode == 'grid' %}p-4{% else %}flex-1 min-w-0{% endif %}">
<h3 class="text-lg font-semibold text-gray-900 truncate group-hover:text-blue-600">
{{ park.name }} {{ park.name }}
</h3> </a>
</h2>
<div class="mt-1 text-sm text-gray-500 truncate"> <div class="flex flex-wrap gap-2">
{% with location=park.location.first %} <span class="status-badge status-{{ park.status|lower }}">
{% if location %}
{{ location.city }}{% if location.state %}, {{ location.state }}{% endif %}{% if location.country %}, {{ location.country }}{% endif %}
{% else %}
Location unknown
{% endif %}
{% endwith %}
</div>
<div class="mt-2 flex flex-wrap gap-2">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium {{ park.get_status_color }} status-badge"
data-testid="park-status">
{{ park.get_status_display }} {{ park.get_status_display }}
</span> </span>
</div>
{% if park.opening_date %} {% if park.owner %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800" <div class="mt-4 text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300">
data-testid="park-opening-date"> <a href="{% url 'companies:company_detail' park.owner.slug %}">
Opened {{ park.opening_date|date:"Y" }} {{ park.owner.name }}
</span> </a>
{% endif %} </div>
{% if park.current_ride_count %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800"
data-testid="park-ride-count">
{{ park.current_ride_count }} ride{{ park.current_ride_count|pluralize }}
</span>
{% endif %}
{% if park.current_coaster_count %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800"
data-testid="park-coaster-count">
{{ park.current_coaster_count }} coaster{{ park.current_coaster_count|pluralize }}
</span>
{% endif %} {% endif %}
</div> </div>
</div> </div>
</article>
{% empty %} {% empty %}
<div class="{% if view_mode == 'grid' %}col-span-full{% endif %} p-4 text-sm text-gray-500 text-center" data-testid="no-parks-found"> <div class="col-span-full p-4 text-sm text-gray-500 text-center" data-testid="no-parks-found">
{% if search_query %} {% if search_query %}
No parks found matching "{{ search_query }}". Try adjusting your search terms. No parks found matching "{{ search_query }}". Try adjusting your search terms.
{% else %} {% else %}

View 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 %}

View File

@@ -0,0 +1,37 @@
{% load filter_utils %}
{% if suggestions %}
<div id="search-suggestions-results"
class="w-full bg-white rounded-md shadow-lg border border-gray-200 max-h-96 overflow-y-auto"
x-show="open"
x-cloak
@keydown.escape.window="open = false"
x-transition:enter="transition ease-out duration-100"
x-transition:enter-start="transform opacity-0 scale-95"
x-transition:enter-end="transform opacity-100 scale-100"
x-transition:leave="transition ease-in duration-75"
x-transition:leave-start="transform opacity-100 scale-100"
x-transition:leave-end="transform opacity-0 scale-95">
{% for park in suggestions %}
{% with location=park.location.first %}
<button type="button"
class="w-full text-left px-4 py-2 text-sm hover:bg-gray-100 cursor-pointer flex items-center justify-between gap-2 transition duration-150"
:class="{ 'bg-blue-50': focusedIndex === {{ forloop.counter0 }} }"
@mousedown.prevent="query = '{{ park.name }}'; $refs.search.value = '{{ park.name }}'"
@mousedown.prevent="query = '{{ park.name }}'; $dispatch('search-selected', '{{ park.name }}'); open = false;"
role="option"
:aria-selected="focusedIndex === {{ forloop.counter0 }}"
tabindex="-1"
x-effect="if(focusedIndex === {{ forloop.counter0 }}) $el.scrollIntoView({block: 'nearest'})"
aria-label="{{ park.name }}{% if location.city %} in {{ location.city }}{% endif %}{% if location.state %}, {{ location.state }}{% endif %}">
<div class="flex items-center gap-2">
<span class="font-medium" x-text="focusedIndex === {{ forloop.counter0 }} ? '▶ {{ park.name }}' : '{{ park.name }}'"></span>
<span class="text-gray-500">
{% if location.city %}{{ location.city }}, {% endif %}
{% if location.state %}{{ location.state }}{% endif %}
</span>
</div>
</button>
{% endwith %}
{% endfor %}
</div>
{% endif %}

127
parks/tests/README.md Normal file
View File

@@ -0,0 +1,127 @@
# Park Search Tests
## Overview
Test suite for the park search functionality including:
- Autocomplete widget integration
- Search form validation
- Filter integration
- HTMX interaction
- View mode persistence
## Running Tests
```bash
# Run all park tests
uv run pytest parks/tests/
# Run specific search tests
uv run pytest parks/tests/test_search.py
# Run with coverage
uv run pytest --cov=parks parks/tests/
```
## Test Coverage
### Search API Tests
- `test_search_json_format`: Validates API response structure
- `test_empty_search_json`: Tests empty search handling
- `test_search_format_validation`: Verifies all required fields and types
- `test_suggestion_limit`: Confirms 8-item result limit
### Search Functionality Tests
- `test_autocomplete_results`: Validates real-time suggestion filtering
- `test_search_with_filters`: Tests filter integration with search
- `test_partial_match_search`: Verifies partial text matching works
### UI Integration Tests
- `test_view_mode_persistence`: Ensures view mode is maintained
- `test_empty_search`: Tests default state behavior
- `test_htmx_request_handling`: Validates HTMX interactions
### 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
Tests use pytest-django and require:
- PostgreSQL database
- HTMX middleware
- Autocomplete app configuration
## Fixtures
The test suite uses standard Django test fixtures. No additional fixtures required.
## Common Issues
1. Database Errors
- Ensure PostGIS extensions are installed
- Verify database permissions
2. HTMX Tests
- Use `HTTP_HX_REQUEST` header for HTMX requests
- Check response content for HTMX attributes
## Adding New Tests
When adding tests, ensure:
1. Database isolation using `@pytest.mark.django_db`
2. Proper test naming following `test_*` convention
3. Clear test descriptions in docstrings
4. Coverage for both success and failure cases
5. HTMX interaction testing where applicable
## Future Improvements
- Add performance benchmarks
- Include accessibility tests
- Add Playwright e2e tests
- Implement geographic search tests

183
parks/tests/test_search.py Normal file
View File

@@ -0,0 +1,183 @@
import pytest
from django.urls import reverse
from django.test import Client
from parks.models import Park
from parks.forms import ParkAutocomplete, ParkSearchForm
@pytest.mark.django_db
class TestParkSearch:
def test_autocomplete_results(self, client: Client):
"""Test that autocomplete returns correct results"""
# Create test parks
park1 = Park.objects.create(name="Test Park")
park2 = Park.objects.create(name="Another Park")
park3 = Park.objects.create(name="Test Garden")
# Get autocomplete results
url = reverse('parks:suggest_parks')
response = client.get(url, {'search': 'Test'})
# Check response
assert response.status_code == 200
content = response.content.decode()
assert park1.name in content
assert park3.name in content
assert park2.name not in content
def test_search_form_valid(self):
"""Test ParkSearchForm validation"""
form = ParkSearchForm(data={})
assert form.is_valid()
def test_autocomplete_class(self):
"""Test ParkAutocomplete configuration"""
ac = ParkAutocomplete()
assert ac.model == Park
assert 'name' in ac.search_attrs
def test_search_with_filters(self, client: Client):
"""Test search works with filters"""
park = Park.objects.create(name="Test Park", status="OPERATING")
# Search with status filter
url = reverse('parks:park_list')
response = client.get(url, {
'park': str(park.pk),
'status': 'OPERATING'
})
assert response.status_code == 200
assert park.name in response.content.decode()
def test_empty_search(self, client: Client):
"""Test empty search returns all parks"""
Park.objects.create(name="Test Park")
Park.objects.create(name="Another Park")
url = reverse('parks:park_list')
response = client.get(url)
assert response.status_code == 200
content = response.content.decode()
assert "Test Park" in content
assert "Another Park" in content
def test_partial_match_search(self, client: Client):
"""Test partial matching in search"""
Park.objects.create(name="Adventure World")
Park.objects.create(name="Water Adventure")
url = reverse('parks:suggest_parks')
response = client.get(url, {'search': 'Adv'})
assert response.status_code == 200
content = response.content.decode()
assert "Adventure World" in content
assert "Water Adventure" in content
def test_htmx_request_handling(self, client: Client):
"""Test HTMX-specific request handling"""
Park.objects.create(name="Test Park")
url = reverse('parks:suggest_parks')
response = client.get(
url,
{'search': 'Test'},
HTTP_HX_REQUEST='true'
)
assert response.status_code == 200
assert "Test Park" in response.content.decode()
def test_view_mode_persistence(self, client: Client):
"""Test view mode is maintained during search"""
Park.objects.create(name="Test Park")
url = reverse('parks:park_list')
response = client.get(url, {
'park': 'Test',
'view_mode': 'list'
})
assert response.status_code == 200
assert 'data-view-mode="list"' in response.content.decode()
def test_suggestion_limit(self, client: Client):
"""Test that suggestions are limited to 8 items"""
# Create 10 parks
for i in range(10):
Park.objects.create(name=f"Test Park {i}")
url = reverse('parks:suggest_parks')
response = client.get(url, {'search': 'Test'})
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']

View File

@@ -1,12 +1,12 @@
from django.urls import path, include from django.urls import path, include
from . import views from . import views, views_search
from rides.views import ParkSingleCategoryListView from rides.views import ParkSingleCategoryListView
app_name = "parks" app_name = "parks"
urlpatterns = [ urlpatterns = [
# Park views # Park views with autocomplete search
path("", views.ParkListView.as_view(), name="park_list"), path("", views_search.ParkSearchView.as_view(), name="park_list"),
path("create/", views.ParkCreateView.as_view(), name="park_create"), path("create/", views.ParkCreateView.as_view(), name="park_create"),
# Add park button endpoint (moved before park detail pattern) # Add park button endpoint (moved before park detail pattern)
@@ -18,6 +18,8 @@ urlpatterns = [
# Areas and search endpoints for HTMX # Areas and search endpoints for HTMX
path("areas/", views.get_park_areas, name="get_park_areas"), path("areas/", views.get_park_areas, name="get_park_areas"),
path("suggest_parks/", views_search.suggest_parks, name="suggest_parks"),
path("search/", views.search_parks, name="search_parks"), path("search/", views.search_parks, name="search_parks"),
# Park detail and related views # Park detail and related views

View File

@@ -1,55 +1,120 @@
from .querysets import get_base_park_queryset
from search.mixins import HTMXFilterableMixin
from reviews.models import Review
from location.models import Location
from media.models import Photo
from moderation.models import EditSubmission
from moderation.mixins import EditSubmissionMixin, PhotoSubmissionMixin, HistoryMixin
from core.views import SlugRedirectMixin
from .filters import ParkFilter
from .forms import ParkForm
from .models import Park, ParkArea
from django.http import HttpResponseRedirect, HttpResponse, HttpRequest, JsonResponse
from django.core.exceptions import ObjectDoesNotExist
from django.contrib import messages
from django.contrib.contenttypes.models import ContentType
from django.contrib.auth.mixins import LoginRequiredMixin
from django.db.models import Q, Count, QuerySet
from django.urls import reverse
from django.shortcuts import get_object_or_404, render
from decimal import InvalidOperation
from django.views.generic import DetailView, ListView, CreateView, UpdateView
import requests
from decimal import Decimal, ROUND_DOWN from decimal import Decimal, ROUND_DOWN
from typing import Any, Optional, cast, Literal from typing import Any, Optional, cast, Literal
from django.views.generic import DetailView, ListView, CreateView, UpdateView
from django.shortcuts import get_object_or_404, render # Constants
from django.urls import reverse PARK_DETAIL_URL = "parks:park_detail"
from django.db.models import Q, Count, QuerySet PARK_LIST_ITEM_TEMPLATE = "parks/partials/park_list_item.html"
from django.contrib.auth.mixins import LoginRequiredMixin REQUIRED_FIELDS_ERROR = "Please correct the errors below. Required fields are marked with an asterisk (*)."
from django.contrib.contenttypes.models import ContentType ALLOWED_ROLES = ["MODERATOR", "ADMIN", "SUPERUSER"]
from django.contrib import messages
from django.core.exceptions import ObjectDoesNotExist
from django.http import HttpResponseRedirect, HttpResponse, HttpRequest, JsonResponse
from .models import Park, ParkArea
from .forms import ParkForm
from .filters import ParkFilter
from core.views import SlugRedirectMixin
from moderation.mixins import EditSubmissionMixin, PhotoSubmissionMixin, HistoryMixin
from moderation.models import EditSubmission
from media.models import Photo
from location.models import Location
from reviews.models import Review
from search.mixins import HTMXFilterableMixin
ViewMode = Literal["grid", "list"] ViewMode = Literal["grid", "list"]
def normalize_osm_result(result: dict) -> dict:
"""Normalize OpenStreetMap result to a consistent format with enhanced address details"""
from .location_utils import get_english_name, normalize_coordinate
# Get address details
address = result.get('address', {})
# Normalize coordinates
lat = normalize_coordinate(float(result.get('lat')), 9, 6)
lon = normalize_coordinate(float(result.get('lon')), 10, 6)
# Get English names where possible
name = ''
if 'namedetails' in result:
name = get_english_name(result['namedetails'])
# Build street address from available components
street_parts = []
if address.get('house_number'):
street_parts.append(address['house_number'])
if address.get('road') or address.get('street'):
street_parts.append(address.get('road') or address.get('street'))
elif address.get('pedestrian'):
street_parts.append(address['pedestrian'])
elif address.get('footway'):
street_parts.append(address['footway'])
# Handle additional address components
suburb = address.get('suburb', '')
district = address.get('district', '')
neighborhood = address.get('neighbourhood', '')
# Build city from available components
city = (address.get('city') or
address.get('town') or
address.get('village') or
address.get('municipality') or
'')
# Get detailed state/region information
state = (address.get('state') or
address.get('province') or
address.get('region') or
'')
# Get postal code with fallbacks
postal_code = (address.get('postcode') or
address.get('postal_code') or
'')
return {
'display_name': name or result.get('display_name', ''),
'lat': lat,
'lon': lon,
'street': ' '.join(street_parts).strip(),
'suburb': suburb,
'district': district,
'neighborhood': neighborhood,
'city': city,
'state': state,
'country': address.get('country', ''),
'postal_code': postal_code,
}
def get_view_mode(request: HttpRequest) -> ViewMode: def get_view_mode(request: HttpRequest) -> ViewMode:
"""Get the current view mode from request, defaulting to grid""" """Get the current view mode from request, defaulting to grid"""
view_mode = request.GET.get('view_mode', 'grid') view_mode = request.GET.get('view_mode', 'grid')
return cast(ViewMode, 'list' if view_mode == 'list' else 'grid') return cast(ViewMode, 'list' if view_mode == 'list' else 'grid')
def get_base_park_queryset() -> QuerySet[Park]:
"""Get base queryset with all needed annotations and prefetches"""
return (
Park.objects.select_related('owner')
.prefetch_related('location', 'photos', 'rides')
.annotate(
current_ride_count=Count('rides', distinct=True),
current_coaster_count=Count('rides', filter=Q(rides__category="RC"), distinct=True)
)
.order_by('name')
)
def add_park_button(request: HttpRequest) -> HttpResponse: def add_park_button(request: HttpRequest) -> HttpResponse:
"""Return the add park button partial template""" """Return the add park button partial template"""
return render(request, "parks/partials/add_park_button.html") return render(request, "parks/partials/add_park_button.html")
def park_actions(request: HttpRequest, slug: str) -> HttpResponse: def park_actions(request: HttpRequest, slug: str) -> HttpResponse:
"""Return the park actions partial template""" """Return the park actions partial template"""
park = get_object_or_404(Park, slug=slug) park = get_object_or_404(Park, slug=slug)
return render(request, "parks/partials/park_actions.html", {"park": park}) return render(request, "parks/partials/park_actions.html", {"park": park})
def get_park_areas(request: HttpRequest) -> HttpResponse: def get_park_areas(request: HttpRequest) -> HttpResponse:
"""Return park areas as options for a select element""" """Return park areas as options for a select element"""
park_id = request.GET.get('park') park_id = request.GET.get('park')
@@ -68,6 +133,7 @@ def get_park_areas(request: HttpRequest) -> HttpResponse:
except Park.DoesNotExist: except Park.DoesNotExist:
return HttpResponse('<option value="">Invalid park selected</option>') return HttpResponse('<option value="">Invalid park selected</option>')
def location_search(request: HttpRequest) -> JsonResponse: def location_search(request: HttpRequest) -> JsonResponse:
"""Search for locations using OpenStreetMap Nominatim API""" """Search for locations using OpenStreetMap Nominatim API"""
query = request.GET.get("q", "") query = request.GET.get("q", "")
@@ -90,7 +156,8 @@ def location_search(request: HttpRequest) -> JsonResponse:
if response.status_code == 200: if response.status_code == 200:
results = response.json() results = response.json()
normalized_results = [normalize_osm_result(result) for result in results] normalized_results = [normalize_osm_result(
result) for result in results]
valid_results = [ valid_results = [
r for r in normalized_results r for r in normalized_results
if r["lat"] is not None and r["lon"] is not None if r["lat"] is not None and r["lon"] is not None
@@ -99,6 +166,7 @@ def location_search(request: HttpRequest) -> JsonResponse:
return JsonResponse({"results": []}) return JsonResponse({"results": []})
def reverse_geocode(request: HttpRequest) -> JsonResponse: def reverse_geocode(request: HttpRequest) -> JsonResponse:
"""Reverse geocode coordinates using OpenStreetMap Nominatim API""" """Reverse geocode coordinates using OpenStreetMap Nominatim API"""
try: try:
@@ -212,6 +280,8 @@ def search_parks(request: HttpRequest) -> HttpResponse:
if not search_query: if not search_query:
return HttpResponse('') return HttpResponse('')
# Get current view mode from request
current_view_mode = request.GET.get('view_mode', 'grid')
park_filter = ParkFilter({ park_filter = ParkFilter({
'search': search_query 'search': search_query
}, queryset=get_base_park_queryset()) }, queryset=get_base_park_queryset())
@@ -222,10 +292,10 @@ def search_parks(request: HttpRequest) -> HttpResponse:
response = render( response = render(
request, request,
"parks/partials/park_list_item.html", PARK_LIST_ITEM_TEMPLATE,
{ {
"parks": parks, "parks": parks,
"view_mode": get_view_mode(request), "view_mode": current_view_mode,
"search_query": search_query, "search_query": search_query,
"is_search": True "is_search": True
} }
@@ -236,7 +306,7 @@ def search_parks(request: HttpRequest) -> HttpResponse:
except Exception as e: except Exception as e:
response = render( response = render(
request, request,
"parks/partials/park_list_item.html", PARK_LIST_ITEM_TEMPLATE,
{ {
"parks": [], "parks": [],
"error": f"Error performing search: {str(e)}", "error": f"Error performing search: {str(e)}",
@@ -246,6 +316,7 @@ def search_parks(request: HttpRequest) -> HttpResponse:
response['HX-Trigger'] = 'searchError' response['HX-Trigger'] = 'searchError'
return response return response
class ParkCreateView(LoginRequiredMixin, CreateView): class ParkCreateView(LoginRequiredMixin, CreateView):
model = Park model = Park
form_class = ParkForm form_class = ParkForm
@@ -259,7 +330,8 @@ class ParkCreateView(LoginRequiredMixin, CreateView):
data["opening_date"] = data["opening_date"].isoformat() data["opening_date"] = data["opening_date"].isoformat()
if data.get("closing_date"): if data.get("closing_date"):
data["closing_date"] = data["closing_date"].isoformat() data["closing_date"] = data["closing_date"].isoformat()
decimal_fields = ["latitude", "longitude", "size_acres", "average_rating"] decimal_fields = ["latitude", "longitude",
"size_acres", "average_rating"]
for field in decimal_fields: for field in decimal_fields:
if data.get(field): if data.get(field):
data[field] = str(data[field]) data[field] = str(data[field])
@@ -292,324 +364,7 @@ class ParkCreateView(LoginRequiredMixin, CreateView):
if hasattr(self.request.user, "role") and getattr( if hasattr(self.request.user, "role") and getattr(
self.request.user, "role", None self.request.user, "role", None
) in ["MODERATOR", "ADMIN", "SUPERUSER"]: ) in ALLOWED_ROLES:
try:
self.object = form.save()
submission.object_id = self.object.id
submission.status = "APPROVED"
submission.handled_by = self.request.user
submission.save()
if form.cleaned_data.get("latitude") and form.cleaned_data.get(
"longitude"
):
Location.objects.create(
content_type=ContentType.objects.get_for_model(Park),
object_id=self.object.id,
name=self.object.name,
location_type="park",
latitude=form.cleaned_data["latitude"],
longitude=form.cleaned_data["longitude"],
street_address=form.cleaned_data.get("street_address", ""),
city=form.cleaned_data.get("city", ""),
state=form.cleaned_data.get("state", ""),
country=form.cleaned_data.get("country", ""),
postal_code=form.cleaned_data.get("postal_code", ""),
)
photos = self.request.FILES.getlist("photos")
uploaded_count = 0
for photo_file in photos:
try:
Photo.objects.create(
image=photo_file,
uploaded_by=self.request.user,
content_type=ContentType.objects.get_for_model(Park),
object_id=self.object.id,
)
uploaded_count += 1
except Exception as e:
messages.error(
self.request,
f"Error uploading photo {photo_file.name}: {str(e)}",
)
messages.success(
self.request,
f"Successfully created {self.object.name}. "
f"Added {uploaded_count} photo(s).",
)
return HttpResponseRedirect(self.get_success_url())
except Exception as e:
messages.error(
self.request,
f"Error creating park: {str(e)}. Please check your input and try again.",
)
return self.form_invalid(form)
messages.success(
self.request,
"Your park submission has been sent for review. "
"You will be notified when it is approved.",
)
return HttpResponseRedirect(reverse("parks:park_list"))
def form_invalid(self, form: ParkForm) -> HttpResponse:
messages.error(
self.request,
"Please correct the errors below. Required fields are marked with an asterisk (*).",
)
for field, errors in form.errors.items():
for error in errors:
messages.error(self.request, f"{field}: {error}")
return super().form_invalid(form)
def get_success_url(self) -> str:
return reverse("parks:park_detail", kwargs={"slug": self.object.slug})
class ParkUpdateView(LoginRequiredMixin, UpdateView):
model = Park
form_class = ParkForm
template_name = "parks/park_form.html"
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
context = super().get_context_data(**kwargs)
context["is_edit"] = True
return context
def prepare_changes_data(self, cleaned_data: dict[str, Any]) -> dict[str, Any]:
data = cleaned_data.copy()
if data.get("owner"):
data["owner"] = data["owner"].id
if data.get("opening_date"):
data["opening_date"] = data["opening_date"].isoformat()
if data.get("closing_date"):
data["closing_date"] = data["closing_date"].isoformat()
decimal_fields = ["latitude", "longitude", "size_acres", "average_rating"]
for field in decimal_fields:
if data.get(field):
data[field] = str(data[field])
return data
def normalize_coordinates(self, form: ParkForm) -> None:
if form.cleaned_data.get("latitude"):
lat = Decimal(str(form.cleaned_data["latitude"]))
form.cleaned_data["latitude"] = lat.quantize(
Decimal("0.000001"), rounding=ROUND_DOWN
)
if form.cleaned_data.get("longitude"):
lon = Decimal(str(form.cleaned_data["longitude"]))
form.cleaned_data["longitude"] = lon.quantize(
Decimal("0.000001"), rounding=ROUND_DOWN
)
def form_valid(self, form: ParkForm) -> HttpResponse:
self.normalize_coordinates(form)
changes = self.prepare_changes_data(form.cleaned_data)
submission = EditSubmission.objects.create(
user=self.request.user,
content_type=ContentType.objects.get_for_model(Park),
object_id=self.object.id,
submission_type="EDIT",
changes=changes,
reason=self.request.POST.get("reason", ""),
source=self.request.POST.get("source", ""),
)
if hasattr(self.request.user, "role") and getattr(
self.request.user, "role", None
) in ["MODERATOR", "ADMIN", "SUPERUSER"]:
try:
self.object = form.save()
submission.status = "APPROVED"
submission.handled_by = self.request.user
submission.save()
location_data = {
"name": self.object.name,
"location_type": "park",
"latitude": form.cleaned_data.get("latitude"),
"longitude": form.cleaned_data.get("longitude"),
"street_address": form.cleaned_data.get("street_address", ""),
"city": form.cleaned_data.get("city", ""),
"state": form.cleaned_data.get("state", ""),
"country": form.cleaned_data.get("country", ""),
"postal_code": form.cleaned_data.get("postal_code", ""),
}
if self.object.location.exists():
location = self.object.location.first()
for key, value in location_data.items():
setattr(location, key, value)
location.save()
else:
Location.objects.create(
content_type=ContentType.objects.get_for_model(Park),
object_id=self.object.id,
**location_data,
)
photos = self.request.FILES.getlist("photos")
uploaded_count = 0
for photo_file in photos:
try:
Photo.objects.create(
image=photo_file,
uploaded_by=self.request.user,
content_type=ContentType.objects.get_for_model(Park),
object_id=self.object.id,
)
uploaded_count += 1
except Exception as e:
messages.error(
self.request,
f"Error uploading photo {photo_file.name}: {str(e)}",
)
messages.success(
self.request,
f"Successfully updated {self.object.name}. "
f"Added {uploaded_count} new photo(s).",
)
return HttpResponseRedirect(self.get_success_url())
except Exception as e:
messages.error(
self.request,
f"Error updating park: {str(e)}. Please check your input and try again.",
)
return self.form_invalid(form)
messages.success(
self.request,
f"Your changes to {self.object.name} have been sent for review. "
"You will be notified when they are approved.",
)
return HttpResponseRedirect(
reverse("parks:park_detail", kwargs={"slug": self.object.slug})
)
def form_invalid(self, form: ParkForm) -> HttpResponse:
messages.error(
self.request,
"Please correct the errors below. Required fields are marked with an asterisk (*).",
)
for field, errors in form.errors.items():
for error in errors:
messages.error(self.request, f"{field}: {error}")
return super().form_invalid(form)
def get_success_url(self) -> str:
return reverse("parks:park_detail", kwargs={"slug": self.object.slug})
class ParkDetailView(
SlugRedirectMixin,
EditSubmissionMixin,
PhotoSubmissionMixin,
HistoryMixin,
DetailView
):
model = Park
template_name = "parks/park_detail.html"
context_object_name = "park"
def get_object(self, queryset: Optional[QuerySet[Park]] = None) -> Park:
if queryset is None:
queryset = self.get_queryset()
slug = self.kwargs.get(self.slug_url_kwarg)
if slug is None:
raise ObjectDoesNotExist("No slug provided")
park, _ = Park.get_by_slug(slug)
return park
def get_queryset(self) -> QuerySet[Park]:
return cast(
QuerySet[Park],
super()
.get_queryset()
.prefetch_related(
"rides",
"rides__manufacturer",
"photos",
"areas",
"location"
),
)
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
context = super().get_context_data(**kwargs)
park = cast(Park, self.object)
context["areas"] = park.areas.all()
context["rides"] = park.rides.all().order_by("-status", "name")
if self.request.user.is_authenticated:
context["has_reviewed"] = Review.objects.filter(
user=self.request.user,
content_type=ContentType.objects.get_for_model(Park),
object_id=park.id,
).exists()
else:
context["has_reviewed"] = False
return context
def get_redirect_url_pattern(self) -> str:
return "parks:park_detail"
class ParkAreaDetailView(
SlugRedirectMixin,
EditSubmissionMixin,
PhotoSubmissionMixin,
HistoryMixin,
DetailView
):
model = ParkArea
template_name = "parks/area_detail.html"
context_object_name = "area"
slug_url_kwarg = "area_slug"
def get_object(self, queryset: Optional[QuerySet[ParkArea]] = None) -> ParkArea:
if queryset is None:
queryset = self.get_queryset()
park_slug = self.kwargs.get("park_slug")
area_slug = self.kwargs.get("area_slug")
if park_slug is None or area_slug is None:
raise ObjectDoesNotExist("Missing slug")
area, _ = ParkArea.get_by_slug(area_slug)
if area.park.slug != park_slug:
raise ObjectDoesNotExist("Park slug doesn't match")
return area
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
context = super().get_context_data(**kwargs)
return context
def get_redirect_url_pattern(self) -> str:
return "parks:park_detail"
def get_redirect_url_kwargs(self) -> dict[str, str]:
area = cast(ParkArea, self.object)
return {"park_slug": area.park.slug, "area_slug": area.slug}
def form_valid(self, form: ParkForm) -> HttpResponse:
self.normalize_coordinates(form)
changes = self.prepare_changes_data(form.cleaned_data)
submission = EditSubmission.objects.create(
user=self.request.user,
content_type=ContentType.objects.get_for_model(Park),
submission_type="CREATE",
changes=changes,
reason=self.request.POST.get("reason", ""),
source=self.request.POST.get("source", ""),
)
if hasattr(self.request.user, "role") and getattr(
self.request.user, "role", None
) in ["MODERATOR", "ADMIN", "SUPERUSER"]:
try: try:
self.object = form.save() self.object = form.save()
submission.object_id = self.object.id submission.object_id = self.object.id
@@ -669,14 +424,7 @@ class ParkAreaDetailView(
messages.success( messages.success(
self.request, self.request,
"Your park submission has been sent for review. " "Your park submission has been sent for review. "
"You will be notified when it is approved.", "You will be notified when it is approved."
)
return HttpResponseRedirect(reverse("parks:park_list"))
def form_invalid(self, form: ParkForm) -> HttpResponse:
messages.error(
self.request,
"Please correct the errors below. Required fields are marked with an asterisk (*).",
) )
for field, errors in form.errors.items(): for field, errors in form.errors.items():
for error in errors: for error in errors:
@@ -684,7 +432,7 @@ class ParkAreaDetailView(
return super().form_invalid(form) return super().form_invalid(form)
def get_success_url(self) -> str: def get_success_url(self) -> str:
return reverse("parks:park_detail", kwargs={"slug": self.object.slug}) return reverse(PARK_DETAIL_URL, kwargs={"slug": self.object.slug})
class ParkUpdateView(LoginRequiredMixin, UpdateView): class ParkUpdateView(LoginRequiredMixin, UpdateView):
@@ -740,7 +488,7 @@ class ParkUpdateView(LoginRequiredMixin, UpdateView):
if hasattr(self.request.user, "role") and getattr( if hasattr(self.request.user, "role") and getattr(
self.request.user, "role", None self.request.user, "role", None
) in ["MODERATOR", "ADMIN", "SUPERUSER"]: ) in ALLOWED_ROLES:
try: try:
self.object = form.save() self.object = form.save()
submission.status = "APPROVED" submission.status = "APPROVED"
@@ -808,13 +556,13 @@ class ParkUpdateView(LoginRequiredMixin, UpdateView):
"You will be notified when they are approved.", "You will be notified when they are approved.",
) )
return HttpResponseRedirect( return HttpResponseRedirect(
reverse("parks:park_detail", kwargs={"slug": self.object.slug}) reverse(PARK_DETAIL_URL, kwargs={"slug": self.object.slug})
) )
def form_invalid(self, form: ParkForm) -> HttpResponse: def form_invalid(self, form: ParkForm) -> HttpResponse:
messages.error( messages.error(
self.request, self.request,
"Please correct the errors below. Required fields are marked with an asterisk (*).", REQUIRED_FIELDS_ERROR
) )
for field, errors in form.errors.items(): for field, errors in form.errors.items():
for error in errors: for error in errors:
@@ -822,7 +570,62 @@ class ParkUpdateView(LoginRequiredMixin, UpdateView):
return super().form_invalid(form) return super().form_invalid(form)
def get_success_url(self) -> str: def get_success_url(self) -> str:
return reverse("parks:park_detail", kwargs={"slug": self.object.slug}) return reverse(PARK_DETAIL_URL, kwargs={"slug": self.object.slug})
class ParkDetailView(
SlugRedirectMixin,
EditSubmissionMixin,
PhotoSubmissionMixin,
HistoryMixin,
DetailView
):
model = Park
template_name = "parks/park_detail.html"
context_object_name = "park"
def get_object(self, queryset: Optional[QuerySet[Park]] = None) -> Park:
if queryset is None:
queryset = self.get_queryset()
slug = self.kwargs.get(self.slug_url_kwarg)
if slug is None:
raise ObjectDoesNotExist("No slug provided")
park, _ = Park.get_by_slug(slug)
return park
def get_queryset(self) -> QuerySet[Park]:
return cast(
QuerySet[Park],
super()
.get_queryset()
.prefetch_related(
"rides",
"rides__manufacturer",
"photos",
"areas",
"location"
),
)
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
context = super().get_context_data(**kwargs)
park = cast(Park, self.object)
context["areas"] = park.areas.all()
context["rides"] = park.rides.all().order_by("-status", "name")
if self.request.user.is_authenticated:
context["has_reviewed"] = Review.objects.filter(
user=self.request.user,
content_type=ContentType.objects.get_for_model(Park),
object_id=park.id,
).exists()
else:
context["has_reviewed"] = False
return context
def get_redirect_url_pattern(self) -> str:
return PARK_DETAIL_URL
class ParkAreaDetailView( class ParkAreaDetailView(
@@ -830,7 +633,7 @@ class ParkAreaDetailView(
EditSubmissionMixin, EditSubmissionMixin,
PhotoSubmissionMixin, PhotoSubmissionMixin,
HistoryMixin, HistoryMixin,
DetailView, DetailView
): ):
model = ParkArea model = ParkArea
template_name = "parks/area_detail.html" template_name = "parks/area_detail.html"
@@ -854,7 +657,7 @@ class ParkAreaDetailView(
return context return context
def get_redirect_url_pattern(self) -> str: def get_redirect_url_pattern(self) -> str:
return "parks:park_detail" return PARK_DETAIL_URL
def get_redirect_url_kwargs(self) -> dict[str, str]: def get_redirect_url_kwargs(self) -> dict[str, str]:
area = cast(ParkArea, self.object) area = cast(ParkArea, self.object)

54
parks/views_search.py Normal file
View File

@@ -0,0 +1,54 @@
from django.http import HttpRequest, HttpResponse, JsonResponse
from django.shortcuts import render
from django.views.generic import TemplateView
from django.urls import reverse
from .filters import ParkFilter
from .forms import ParkSearchForm
from .querysets import get_base_park_queryset
class ParkSearchView(TemplateView):
"""View for handling park search with autocomplete."""
template_name = "parks/park_list.html"
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['search_form'] = ParkSearchForm(self.request.GET)
# Initialize filter with current querystring
queryset = get_base_park_queryset()
filter_instance = ParkFilter(self.request.GET, queryset=queryset)
context['filter'] = filter_instance
# Apply search if park ID selected via autocomplete
park_id = self.request.GET.get('park')
if park_id:
queryset = filter_instance.qs.filter(id=park_id)
else:
queryset = filter_instance.qs
# Handle view mode
context['view_mode'] = self.request.GET.get('view_mode', 'grid')
context['parks'] = queryset
return context
def suggest_parks(request: HttpRequest) -> JsonResponse:
"""Return park search suggestions as JSON."""
query = request.GET.get('search', '').strip()
if not query:
return JsonResponse({'results': []})
queryset = get_base_park_queryset()
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})

View File

@@ -57,4 +57,5 @@ dependencies = [
"playwright>=1.41.0", "playwright>=1.41.0",
"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",
] ]

View File

@@ -0,0 +1,20 @@
# Generated by Django 5.1.4 on 2025-02-21 17:55
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("reviews", "0001_initial"),
]
operations = [
migrations.AlterField(
model_name="review",
name="id",
field=models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
]

View File

@@ -0,0 +1,76 @@
# Generated by Django 5.1.4 on 2025-02-21 17:55
import pgtrigger.migrations
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("parks", "0003_alter_park_id_alter_parkarea_id_and_more"),
("rides", "0005_fix_event_context_fields"),
]
operations = [
migrations.AlterModelOptions(
name="rideevent",
options={"managed": False},
),
migrations.AlterModelOptions(
name="ridemodelevent",
options={"managed": False},
),
pgtrigger.migrations.RemoveTrigger(
model_name="ride",
name="insert_insert",
),
pgtrigger.migrations.RemoveTrigger(
model_name="ride",
name="update_update",
),
pgtrigger.migrations.RemoveTrigger(
model_name="ridemodel",
name="insert_insert",
),
pgtrigger.migrations.RemoveTrigger(
model_name="ridemodel",
name="update_update",
),
migrations.AlterField(
model_name="ride",
name="id",
field=models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
migrations.AlterField(
model_name="ride",
name="status",
field=models.CharField(
choices=[
("", "Select status"),
("OPERATING", "Operating"),
("CLOSED_TEMP", "Temporarily Closed"),
("SBNO", "Standing But Not Operating"),
("CLOSING", "Closing"),
("CLOSED_PERM", "Permanently Closed"),
("UNDER_CONSTRUCTION", "Under Construction"),
("DEMOLISHED", "Demolished"),
("RELOCATED", "Relocated"),
],
default="OPERATING",
max_length=20,
),
),
migrations.AlterField(
model_name="ridemodel",
name="id",
field=models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
migrations.AlterUniqueTogether(
name="ride",
unique_together={("park", "slug")},
),
]

View File

@@ -1,16 +1,21 @@
{% load static %} {% load static %}
{% load filter_utils %}
<div class="filter-container bg-white rounded-lg shadow p-4" x-data="{ open: false }"> <div class="filter-container" x-data="{ open: false }">
{# Mobile Filter Toggle #} {# Mobile Filter Toggle #}
<div class="lg:hidden"> <div class="lg:hidden bg-white rounded-lg shadow p-4 mb-4">
<button @click="open = !open" type="button" class="w-full flex items-center justify-between p-2 text-gray-400 hover:text-gray-500"> <button @click="open = !open" type="button" class="w-full flex items-center justify-between p-2">
<span class="font-medium text-gray-900">Filters</span> <span class="font-medium text-gray-900">
<span class="ml-6 flex items-center"> <span class="mr-2">
<svg class="w-5 h-5" x-show="!open" fill="currentColor" viewBox="0 0 20 20"> <svg class="w-5 h-5 inline-block" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path d="M10 6L16 12H4L10 6Z"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4"></path>
</svg> </svg>
<svg class="w-5 h-5" x-show="open" fill="currentColor" viewBox="0 0 20 20"> </span>
<path d="M10 14L4 8H16L10 14Z"/> Filter Options
</span>
<span class="text-gray-500">
<svg class="w-5 h-5 transition-transform duration-200" :class="{'rotate-180': open}" fill="currentColor" viewBox="0 0 20 20">
<path d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"></path>
</svg> </svg>
</span> </span>
</button> </button>
@@ -18,20 +23,23 @@
{# Filter Form #} {# Filter Form #}
<form hx-get="{{ request.path }}" <form hx-get="{{ request.path }}"
hx-trigger="change delay:500ms, submit" hx-trigger="change delay:500ms"
hx-target="#results-container" hx-target="#results-container"
hx-push-url="true" hx-push-url="true"
class="mt-4 lg:mt-0" class="space-y-6"
x-show="open || $screen('lg')" x-show="open || $screen('lg')"
x-transition> x-transition>
{# Active Filters Summary #} {# Active Filters Summary #}
{% if applied_filters %} {% if applied_filters %}
<div class="bg-blue-50 p-4 rounded-lg mb-4"> <div class="bg-blue-50 rounded-lg p-4 shadow-sm border border-blue-100">
<div class="flex justify-between items-center"> <div class="flex justify-between items-center">
<div>
<h3 class="text-sm font-medium text-blue-800">Active Filters</h3> <h3 class="text-sm font-medium text-blue-800">Active Filters</h3>
<p class="text-xs text-blue-600 mt-1">{{ applied_filters|length }} filter{{ applied_filters|length|pluralize }} applied</p>
</div>
<a href="{{ request.path }}" <a href="{{ request.path }}"
class="text-sm text-blue-600 hover:text-blue-500" class="text-sm font-medium text-blue-600 hover:text-blue-500 hover:underline"
hx-get="{{ request.path }}" hx-get="{{ request.path }}"
hx-target="#results-container" hx-target="#results-container"
hx-push-url="true"> hx-push-url="true">
@@ -42,21 +50,35 @@
{% endif %} {% endif %}
{# Filter Groups #} {# Filter Groups #}
<div class="space-y-4"> <div class="bg-white rounded-lg shadow divide-y divide-gray-200">
{% for fieldset in filter.form|groupby_filters %} {% for fieldset in filter.form|groupby_filters %}
<div class="border-b border-gray-200 pb-4"> <div class="p-6" x-data="{ expanded: true }">
<h3 class="text-sm font-medium text-gray-900 mb-3">{{ fieldset.name }}</h3> {# Group Header #}
<div class="space-y-3"> <button type="button"
@click="expanded = !expanded"
class="w-full flex justify-between items-center text-left">
<h3 class="text-lg font-medium text-gray-900">{{ fieldset.name }}</h3>
<svg class="w-5 h-5 text-gray-500 transform transition-transform duration-200"
:class="{'rotate-180': !expanded}"
fill="currentColor"
viewBox="0 0 20 20">
<path d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"/>
</svg>
</button>
{# Group Content #}
<div class="mt-4 space-y-4" x-show="expanded" x-collapse>
{% for field in fieldset.fields %} {% for field in fieldset.fields %}
<div> <div class="filter-field">
<label for="{{ field.id_for_label }}" class="text-sm text-gray-600"> <label for="{{ field.id_for_label }}"
class="block text-sm font-medium text-gray-700 mb-1">
{{ field.label }} {{ field.label }}
</label> </label>
<div class="mt-1"> <div class="mt-1">
{{ field }} {{ field|add_field_classes }}
</div> </div>
{% if field.help_text %} {% if field.help_text %}
<p class="mt-1 text-xs text-gray-500">{{ field.help_text }}</p> <p class="mt-1 text-sm text-gray-500">{{ field.help_text }}</p>
{% endif %} {% endif %}
</div> </div>
{% endfor %} {% endfor %}
@@ -65,17 +87,25 @@
{% endfor %} {% endfor %}
</div> </div>
{# Submit Button - Only visible on mobile #} {# Mobile Apply Button #}
<div class="mt-4 lg:hidden"> <div class="lg:hidden">
<button type="submit" <button type="submit"
class="w-full bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500"> class="w-full bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition duration-150 ease-in-out">
Apply Filters Apply Filters
</button> </button>
</div> </div>
</form> </form>
</div> </div>
{% block extra_scripts %} {# Required Scripts #}
{# Add Alpine.js for mobile menu toggle if not already included #}
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script> <script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
{% endblock %} <script>
document.addEventListener('alpine:init', () => {
Alpine.data('filterForm', () => ({
expanded: true,
toggle() {
this.expanded = !this.expanded
}
}))
})
</script>

View File

@@ -32,17 +32,18 @@ def groupby_filters(form: Form) -> List[Dict[str, Any]]:
""" """
groups = [] groups = []
# Define groups and their patterns # Define groups and their patterns with specific ordering
group_patterns = { group_patterns = {
'Search': lambda f: f.name in ['search', 'q'], 'Quick Search': lambda f: f.name in ['search', 'q'],
'Park Details': lambda f: f.name in ['status', 'has_owner', 'owner'],
'Attractions': lambda f: any(x in f.name for x in ['rides', 'coasters']),
'Park Size': lambda f: 'size' in f.name,
'Location': lambda f: f.name.startswith('location') or 'address' in f.name, 'Location': lambda f: f.name.startswith('location') or 'address' in f.name,
'Dates': lambda f: any(x in f.name for x in ['date', 'created', 'updated']), 'Ratings': lambda f: 'rating' in f.name,
'Rating': lambda f: 'rating' in f.name, 'Opening Info': lambda f: 'opening' in f.name or 'date' in f.name,
'Status': lambda f: f.name in ['status', 'state', 'condition'],
'Features': lambda f: f.name.startswith('has_') or f.name.endswith('_count'),
} }
# Initialize group containers # Initialize group containers with ordering preserved
grouped_fields: Dict[str, List] = {name: [] for name in group_patterns.keys()} grouped_fields: Dict[str, List] = {name: [] for name in group_patterns.keys()}
ungrouped = [] ungrouped = []
@@ -57,7 +58,7 @@ def groupby_filters(form: Form) -> List[Dict[str, Any]]:
if not grouped: if not grouped:
ungrouped.append(field) ungrouped.append(field)
# Build final groups list, only including non-empty groups # Build final groups list, maintaining order and only including non-empty groups
for name, fields in grouped_fields.items(): for name, fields in grouped_fields.items():
if fields: if fields:
groups.append({ groups.append({
@@ -68,7 +69,7 @@ def groupby_filters(form: Form) -> List[Dict[str, Any]]:
# Add ungrouped fields at the end if any exist # Add ungrouped fields at the end if any exist
if ungrouped: if ungrouped:
groups.append({ groups.append({
'name': 'Other', 'name': 'Other Filters',
'fields': ungrouped 'fields': ungrouped
}) })
@@ -86,15 +87,26 @@ def add_field_classes(field: Any) -> Any:
""" """
Add appropriate Tailwind classes based on field type Add appropriate Tailwind classes based on field type
""" """
base_classes = "transition duration-150 ease-in-out "
classes = { classes = {
'default': 'mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-300 focus:ring focus:ring-blue-200 focus:ring-opacity-50', 'default': base_classes + 'mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 focus:ring-opacity-50',
'checkbox': 'rounded border-gray-300 text-blue-600 shadow-sm focus:border-blue-300 focus:ring focus:ring-offset-0 focus:ring-blue-200 focus:ring-opacity-50', 'checkbox': base_classes + 'h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-2 focus:ring-blue-500',
'radio': 'border-gray-300 text-blue-600 shadow-sm focus:border-blue-300 focus:ring focus:ring-offset-0 focus:ring-blue-200 focus:ring-opacity-50', 'radio': base_classes + 'h-4 w-4 border-gray-300 text-blue-600 focus:ring-2 focus:ring-blue-500',
'select': 'mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-300 focus:ring focus:ring-blue-200 focus:ring-opacity-50', 'select': base_classes + 'mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 focus:ring-opacity-50',
'multiselect': 'mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-300 focus:ring focus:ring-blue-200 focus:ring-opacity-50', 'multiselect': base_classes + 'mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 focus:ring-opacity-50',
'range': base_classes + 'mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 focus:ring-opacity-50',
'dateinput': base_classes + 'mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 focus:ring-opacity-50',
} }
field_type = get_field_type(field) field_type = get_field_type(field)
css_class = classes.get(field_type, classes['default']) css_class = classes.get(field_type, classes['default'])
return field.as_widget(attrs={'class': css_class}) current_attrs = field.field.widget.attrs
current_attrs['class'] = css_class
# Add specific attributes for certain field types
if field_type == 'dateinput':
current_attrs['type'] = 'date'
return field.as_widget(attrs=current_attrs)

42
static/js/search.js Normal file
View 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'));
}
};
}

View File

@@ -223,7 +223,13 @@ document.addEventListener('DOMContentLoaded', function() {
data-result-index="${index}"> data-result-index="${index}">
<div class="font-medium text-gray-900 dark:text-white">${result.display_name || result.name || ''}</div> <div class="font-medium text-gray-900 dark:text-white">${result.display_name || result.name || ''}</div>
<div class="text-sm text-gray-500 dark:text-gray-400"> <div class="text-sm text-gray-500 dark:text-gray-400">
${(result.address && result.address.city) ? result.address.city + ', ' : ''}${(result.address && result.address.country) || ''} ${[
result.street,
result.city || (result.address && (result.address.city || result.address.town || result.address.village)),
result.state || (result.address && (result.address.state || result.address.region)),
result.country || (result.address && result.address.country),
result.postal_code || (result.address && result.address.postcode)
].filter(Boolean).join(', ')}
</div> </div>
</div> </div>
`).join(''); `).join('');
@@ -313,12 +319,12 @@ document.addEventListener('DOMContentLoaded', function() {
const address = { const address = {
name: result.display_name || result.name || '', name: result.display_name || result.name || '',
address: { address: {
house_number: result.address ? result.address.house_number : '', house_number: result.house_number || (result.address && result.address.house_number) || '',
road: result.address ? (result.address.road || result.address.street) : '', road: result.street || (result.address && (result.address.road || result.address.street)) || '',
city: result.address ? (result.address.city || result.address.town || result.address.village) : '', city: result.city || (result.address && (result.address.city || result.address.town || result.address.village)) || '',
state: result.address ? (result.address.state || result.address.region) : '', state: result.state || (result.address && (result.address.state || result.address.region)) || '',
country: result.address ? result.address.country : '', country: result.country || (result.address && result.address.country) || '',
postcode: result.address ? result.address.postcode : '' postcode: result.postal_code || (result.address && result.address.postcode) || ''
} }
}; };

View File

@@ -42,6 +42,7 @@ INSTALLED_APPS = [
"django_htmx", "django_htmx",
"whitenoise", "whitenoise",
"django_tailwind_cli", "django_tailwind_cli",
"autocomplete", # Django HTMX Autocomplete
"core", "core",
"accounts", "accounts",
"companies", "companies",
@@ -208,10 +209,14 @@ SOCIALACCOUNT_STORE_TOKENS = True
EMAIL_BACKEND = "email_service.backends.ForwardEmailBackend" EMAIL_BACKEND = "email_service.backends.ForwardEmailBackend"
FORWARD_EMAIL_BASE_URL = "https://api.forwardemail.net" FORWARD_EMAIL_BASE_URL = "https://api.forwardemail.net"
SERVER_EMAIL = "django_webmaster@thrillwiki.com" SERVER_EMAIL = "django_webmaster@thrillwiki.com"
# Custom User Model # Custom User Model
AUTH_USER_MODEL = "accounts.User" AUTH_USER_MODEL = "accounts.User"
# Autocomplete configuration
# Enable project-wide authentication requirement for autocomplete
AUTOCOMPLETE_BLOCK_UNAUTHENTICATED = False
# Tailwind configuration
# Tailwind configuration # Tailwind configuration
TAILWIND_CLI_CONFIG_FILE = os.path.join(BASE_DIR, "tailwind.config.js") TAILWIND_CLI_CONFIG_FILE = os.path.join(BASE_DIR, "tailwind.config.js")
TAILWIND_CLI_SRC_CSS = os.path.join(BASE_DIR, "static/css/src/input.css") TAILWIND_CLI_SRC_CSS = os.path.join(BASE_DIR, "static/css/src/input.css")

15
uv.lock generated
View File

@@ -1,4 +1,5 @@
version = 1 version = 1
revision = 1
requires-python = ">=3.13" requires-python = ">=3.13"
[[package]] [[package]]
@@ -313,6 +314,18 @@ wheels = [
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]django_htmx-1.21.0-py3-none-any.whl", hash = "sha256:[AWS-SECRET-REMOVED]e7ccd2963495e69afbdb6abe", size = 6901 }, { url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]django_htmx-1.21.0-py3-none-any.whl", hash = "sha256:[AWS-SECRET-REMOVED]e7ccd2963495e69afbdb6abe", size = 6901 },
] ]
[[package]]
name = "django-htmx-autocomplete"
version = "1.0.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
]
sdist = { url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]django_htmx_autocomplete-1.0.5.tar.gz", hash = "sha256:[AWS-SECRET-REMOVED]17bcac3ff0b70766e354ad80", size = 41127 }
wheels = [
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]django_htmx_autocomplete-1.0.5-py3-none-any.whl", hash = "sha256:[AWS-SECRET-REMOVED]3572e8742fe5dfa848298735", size = 52127 },
]
[[package]] [[package]]
name = "django-oauth-toolkit" name = "django-oauth-toolkit"
version = "3.0.1" version = "3.0.1"
@@ -912,6 +925,7 @@ dependencies = [
{ name = "django-cors-headers" }, { name = "django-cors-headers" },
{ name = "django-filter" }, { name = "django-filter" },
{ name = "django-htmx" }, { name = "django-htmx" },
{ name = "django-htmx-autocomplete" },
{ name = "django-oauth-toolkit" }, { name = "django-oauth-toolkit" },
{ name = "django-pghistory" }, { name = "django-pghistory" },
{ name = "django-simple-history" }, { name = "django-simple-history" },
@@ -946,6 +960,7 @@ requires-dist = [
{ name = "django-cors-headers", specifier = ">=4.3.1" }, { name = "django-cors-headers", specifier = ">=4.3.1" },
{ name = "django-filter", specifier = ">=23.5" }, { name = "django-filter", specifier = ">=23.5" },
{ name = "django-htmx", specifier = ">=1.17.2" }, { name = "django-htmx", specifier = ">=1.17.2" },
{ name = "django-htmx-autocomplete", specifier = ">=1.0.5" },
{ name = "django-oauth-toolkit", specifier = ">=3.0.1" }, { name = "django-oauth-toolkit", specifier = ">=3.0.1" },
{ name = "django-pghistory", specifier = ">=3.5.2" }, { name = "django-pghistory", specifier = ">=3.5.2" },
{ name = "django-simple-history", specifier = ">=3.5.0" }, { name = "django-simple-history", specifier = ">=3.5.0" },