mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 16:11:08 -05:00
Compare commits
22 Commits
pixeebot/d
...
nestjs
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
046257d06c | ||
|
|
c9ab1f40ed | ||
|
|
730b165f9c | ||
|
|
401449201c | ||
|
|
1ca1362fee | ||
|
|
02e4b82beb | ||
|
|
4339c5c5e0 | ||
|
|
5278ad39d0 | ||
|
|
4d145ebabe | ||
|
|
e4959b7a04 | ||
|
|
ef2437b7f4 | ||
|
|
3523274cbd | ||
|
|
d7951756dc | ||
|
|
518fcbee22 | ||
|
|
41f1738cc1 | ||
|
|
645a74a4c3 | ||
|
|
8c85b2afd4 | ||
|
|
063398d220 | ||
|
|
20ae4862e4 | ||
|
|
5541a5f02d | ||
|
|
78f465b273 | ||
|
|
0b51ee123a |
30
.clinerules
Normal file
30
.clinerules
Normal 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.
|
||||||
@@ -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",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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
39
core/forms.py
Normal 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"))
|
||||||
20
designers/migrations/0002_alter_designer_id.py
Normal file
20
designers/migrations/0002_alter_designer_id.py
Normal 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"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
20
email_service/migrations/0002_alter_emailconfiguration_id.py
Normal file
20
email_service/migrations/0002_alter_emailconfiguration_id.py
Normal 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
41
frontend/.gitignore
vendored
Normal 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
36
frontend/README.md
Normal 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.
|
||||||
16
frontend/eslint.config.mjs
Normal file
16
frontend/eslint.config.mjs
Normal 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
7
frontend/next.config.ts
Normal 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
6898
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
34
frontend/package.json
Normal file
34
frontend/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
8
frontend/postcss.config.mjs
Normal file
8
frontend/postcss.config.mjs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
/** @type {import('postcss-load-config').Config} */
|
||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
116
frontend/prisma/migrations/20250223184307_init/migration.sql
Normal file
116
frontend/prisma/migrations/20250223184307_init/migration.sql
Normal 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");
|
||||||
3
frontend/prisma/migrations/migration_lock.toml
Normal file
3
frontend/prisma/migrations/migration_lock.toml
Normal 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"
|
||||||
137
frontend/prisma/schema.prisma
Normal file
137
frontend/prisma/schema.prisma
Normal 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
148
frontend/prisma/seed.ts
Normal 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
1
frontend/public/file.svg
Normal 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 |
1
frontend/public/globe.svg
Normal file
1
frontend/public/globe.svg
Normal 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
1
frontend/public/next.svg
Normal 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 |
1
frontend/public/vercel.svg
Normal file
1
frontend/public/vercel.svg
Normal 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 |
1
frontend/public/window.svg
Normal file
1
frontend/public/window.svg
Normal 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 |
103
frontend/src/app/api/parks/[slug]/route.ts
Normal file
103
frontend/src/app/api/parks/[slug]/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
88
frontend/src/app/api/parks/route.ts
Normal file
88
frontend/src/app/api/parks/route.ts
Normal 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'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
50
frontend/src/app/api/parks/suggest/route.ts
Normal file
50
frontend/src/app/api/parks/suggest/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
frontend/src/app/favicon.ico
Normal file
BIN
frontend/src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
21
frontend/src/app/globals.css
Normal file
21
frontend/src/app/globals.css
Normal 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;
|
||||||
|
}
|
||||||
62
frontend/src/app/layout.tsx
Normal file
62
frontend/src/app/layout.tsx
Normal 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">
|
||||||
|
© {new Date().getFullYear()} ThrillWiki. All rights reserved.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
84
frontend/src/app/page.tsx
Normal file
84
frontend/src/app/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
99
frontend/src/app/parks/[slug]/loading.tsx
Normal file
99
frontend/src/app/parks/[slug]/loading.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
194
frontend/src/app/parks/[slug]/page.tsx
Normal file
194
frontend/src/app/parks/[slug]/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
150
frontend/src/app/parks/page.tsx
Normal file
150
frontend/src/app/parks/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
81
frontend/src/components/error-boundary.tsx
Normal file
81
frontend/src/components/error-boundary.tsx
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
55
frontend/src/components/parks/ParkCard.tsx
Normal file
55
frontend/src/components/parks/ParkCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
182
frontend/src/components/parks/ParkFilters.tsx
Normal file
182
frontend/src/components/parks/ParkFilters.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
frontend/src/components/parks/ParkList.tsx
Normal file
41
frontend/src/components/parks/ParkList.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
70
frontend/src/components/parks/ParkListItem.tsx
Normal file
70
frontend/src/components/parks/ParkListItem.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
105
frontend/src/components/parks/ParkSearch.tsx
Normal file
105
frontend/src/components/parks/ParkSearch.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
frontend/src/components/parks/ViewToggle.tsx
Normal file
48
frontend/src/components/parks/ViewToggle.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
frontend/src/middleware.ts
Normal file
25
frontend/src/middleware.ts
Normal 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*',
|
||||||
|
]
|
||||||
|
};
|
||||||
38
frontend/src/scripts/db-reset.ts
Normal file
38
frontend/src/scripts/db-reset.ts
Normal 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
83
frontend/src/types/api.ts
Normal 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[]>;
|
||||||
18
frontend/tailwind.config.ts
Normal file
18
frontend/tailwind.config.ts
Normal 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
27
frontend/tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
20
location/migrations/0002_alter_location_id.py
Normal file
20
location/migrations/0002_alter_location_id.py
Normal 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"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
20
media/migrations/0002_alter_photo_id.py
Normal file
20
media/migrations/0002_alter_photo_id.py
Normal 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"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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
|
||||||
|
|||||||
254
memory-bank/decisions/laravel_migration_analysis.md
Normal file
254
memory-bank/decisions/laravel_migration_analysis.md
Normal 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
|
||||||
146
memory-bank/decisions/nextjs_migration.md
Normal file
146
memory-bank/decisions/nextjs_migration.md
Normal 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
|
||||||
71
memory-bank/decisions/park-search-improvements.md
Normal file
71
memory-bank/decisions/park-search-improvements.md
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
# Park Search Implementation Improvements
|
||||||
|
|
||||||
|
## Context
|
||||||
|
The park search functionality needed to be updated to follow consistent patterns across the application and strictly adhere to the "NO CUSTOM JS" rule. Previously, search functionality was inconsistent and did not fully utilize built-in framework features.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
Implemented a unified search pattern that:
|
||||||
|
1. Uses only built-in HTMX and Alpine.js features
|
||||||
|
2. Matches location search pattern
|
||||||
|
3. Removes any custom JavaScript files
|
||||||
|
4. Maintains consistency across the application
|
||||||
|
|
||||||
|
### Benefits
|
||||||
|
1. **Simplified Architecture:**
|
||||||
|
- No custom JavaScript files needed
|
||||||
|
- Direct template-based implementation
|
||||||
|
- Reduced maintenance burden
|
||||||
|
- Smaller codebase
|
||||||
|
|
||||||
|
2. **Framework Alignment:**
|
||||||
|
- Uses HTMX for AJAX requests
|
||||||
|
- Uses Alpine.js for state management
|
||||||
|
- All functionality in templates
|
||||||
|
- Follows project patterns
|
||||||
|
|
||||||
|
3. **Better Maintainability:**
|
||||||
|
- Single source of truth in templates
|
||||||
|
- Reduced complexity
|
||||||
|
- Easier to understand
|
||||||
|
- Consistent with other features
|
||||||
|
|
||||||
|
## Implementation Details
|
||||||
|
|
||||||
|
### Template Features
|
||||||
|
1. HTMX Integration:
|
||||||
|
- Debounced search requests (300ms)
|
||||||
|
- Loading indicators
|
||||||
|
- JSON response handling
|
||||||
|
|
||||||
|
2. Alpine.js Usage:
|
||||||
|
- State management in template
|
||||||
|
- Event handling
|
||||||
|
- UI updates
|
||||||
|
- Keyboard interactions
|
||||||
|
|
||||||
|
### Backend Changes
|
||||||
|
1. JSON API:
|
||||||
|
- Consistent response format
|
||||||
|
- Type validation
|
||||||
|
- Limited results (8 items)
|
||||||
|
- Performance optimization
|
||||||
|
|
||||||
|
2. View Updates:
|
||||||
|
- Search filtering
|
||||||
|
- Result formatting
|
||||||
|
- Error handling
|
||||||
|
- State preservation
|
||||||
|
|
||||||
|
## Benefits
|
||||||
|
1. Better adherence to project standards
|
||||||
|
2. Simplified codebase
|
||||||
|
3. Reduced technical debt
|
||||||
|
4. Easier maintenance
|
||||||
|
5. Consistent user experience
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
1. API response format
|
||||||
|
2. Empty search handling
|
||||||
|
3. Field validation
|
||||||
|
4. UI interactions
|
||||||
|
5. State management
|
||||||
24
memory-bank/decisions/search-form-fix.md
Normal file
24
memory-bank/decisions/search-form-fix.md
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Search Form Fix
|
||||||
|
|
||||||
|
## Issue
|
||||||
|
Search results were being duplicated because selecting a suggestion triggered both:
|
||||||
|
1. The suggestions form submission (to /suggest_parks/)
|
||||||
|
2. The filter form submission (to /park_list/)
|
||||||
|
|
||||||
|
## Root Cause
|
||||||
|
The `@search-selected` event handler was submitting the wrong form. It was submitting the suggestions form which has `hx-target="#search-results"` instead of the filter form which has `hx-target="#park-results"`.
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
Update the event handler to submit the filter form instead of the search form. This ensures only one request is made to update the results.
|
||||||
|
|
||||||
|
## Implementation
|
||||||
|
1. Modified the `@search-selected` handler to:
|
||||||
|
- Set the search query in filter form
|
||||||
|
- Submit filter form to update results
|
||||||
|
- Hide suggestions dropdown
|
||||||
|
2. Added proper form IDs and refs
|
||||||
|
|
||||||
|
## Benefits
|
||||||
|
- Eliminates duplicate requests
|
||||||
|
- Maintains correct search behavior
|
||||||
|
- Improves user experience
|
||||||
410
memory-bank/documentation/APIs.md
Normal file
410
memory-bank/documentation/APIs.md
Normal 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),
|
||||||
|
]
|
||||||
168
memory-bank/documentation/Architecture.md
Normal file
168
memory-bank/documentation/Architecture.md
Normal 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)
|
||||||
287
memory-bank/documentation/Code.md
Normal file
287
memory-bank/documentation/Code.md
Normal 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
|
||||||
327
memory-bank/documentation/Data.md
Normal file
327
memory-bank/documentation/Data.md
Normal 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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
253
memory-bank/documentation/Features.md
Normal file
253
memory-bank/documentation/Features.md
Normal 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
|
||||||
306
memory-bank/documentation/Issues.md
Normal file
306
memory-bank/documentation/Issues.md
Normal 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
|
||||||
388
memory-bank/documentation/Performance.md
Normal file
388
memory-bank/documentation/Performance.md
Normal 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'
|
||||||
|
)
|
||||||
339
memory-bank/documentation/Security.md
Normal file
339
memory-bank/documentation/Security.md
Normal 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
|
||||||
350
memory-bank/documentation/Testing.md
Normal file
350
memory-bank/documentation/Testing.md
Normal 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
|
||||||
|
}
|
||||||
63
memory-bank/features/autocomplete/base.md
Normal file
63
memory-bank/features/autocomplete/base.md
Normal 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
|
||||||
165
memory-bank/features/frontend_structure.md
Normal file
165
memory-bank/features/frontend_structure.md
Normal 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
|
||||||
155
memory-bank/features/nextjs_migration.md
Normal file
155
memory-bank/features/nextjs_migration.md
Normal 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
|
||||||
25
memory-bank/features/park_detail_api.md
Normal file
25
memory-bank/features/park_detail_api.md
Normal 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
|
||||||
44
memory-bank/features/park_detail_page.md
Normal file
44
memory-bank/features/park_detail_page.md
Normal 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
|
||||||
130
memory-bank/features/parks/search.md
Normal file
130
memory-bank/features/parks/search.md
Normal 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
|
||||||
128
memory-bank/features/parks_page_nextjs.md
Normal file
128
memory-bank/features/parks_page_nextjs.md
Normal 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
|
||||||
119
memory-bank/features/search_improvements.md
Normal file
119
memory-bank/features/search_improvements.md
Normal 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
|
||||||
@@ -73,8 +73,8 @@
|
|||||||
```html
|
```html
|
||||||
<!-- Component Structure -->
|
<!-- Component Structure -->
|
||||||
<div class="component-wrapper">
|
<div class="component-wrapper">
|
||||||
<div class="component-header"></div>
|
<div class="component-header"></div>
|
||||||
<div class="component-content"></div>
|
<div class="component-content"></div>
|
||||||
</div>
|
</div>
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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,25 +116,26 @@ 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
|
|
||||||
def qs(self):
|
@property
|
||||||
"""Override qs property to ensure we always use base queryset with annotations"""
|
def qs(self):
|
||||||
if not hasattr(self, '_qs'):
|
"""Override qs property to ensure we always use base queryset with annotations"""
|
||||||
# Start with the base queryset that includes annotations
|
if not hasattr(self, '_qs'):
|
||||||
base_qs = get_base_park_queryset()
|
# Start with the base queryset that includes annotations
|
||||||
|
base_qs = get_base_park_queryset()
|
||||||
if not self.is_bound:
|
|
||||||
self._qs = base_qs
|
if not self.is_bound:
|
||||||
return self._qs
|
self._qs = base_qs
|
||||||
|
return self._qs
|
||||||
if not self.form.is_valid():
|
|
||||||
self._qs = base_qs.none()
|
if not self.form.is_valid():
|
||||||
return self._qs
|
self._qs = base_qs.none()
|
||||||
|
return self._qs
|
||||||
|
|
||||||
self._qs = base_qs
|
self._qs = base_qs
|
||||||
for name, value in self.form.cleaned_data.items():
|
for name, value in self.form.cleaned_data.items():
|
||||||
if value in [None, '', 0] and name not in ['has_owner']:
|
if value in [None, '', 0] and name not in ['has_owner']:
|
||||||
continue
|
continue
|
||||||
self._qs = self.filters[name].filter(self._qs, value)
|
self._qs = self.filters[name].filter(self._qs, value)
|
||||||
self._qs = self._qs.distinct()
|
self._qs = self._qs.distinct()
|
||||||
return self._qs
|
return self._qs
|
||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
22
parks/querysets.py
Normal 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')
|
||||||
|
)
|
||||||
@@ -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"
|
||||||
<input type="search"
|
x-data="{ query: '', selectedId: null }"
|
||||||
name="search"
|
@search-selected.window="
|
||||||
id="search"
|
query = $event.detail;
|
||||||
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"
|
selectedId = $event.target.value;
|
||||||
placeholder="Search parks by name or location..."
|
$refs.filterForm.querySelector('input[name=search]').value = query;
|
||||||
hx-get="{% url 'parks:search_parks' %}"
|
$refs.filterForm.submit();
|
||||||
hx-trigger="input delay:300ms, search"
|
query = '';
|
||||||
hx-target="#park-results"
|
">
|
||||||
hx-push-url="true"
|
<form hx-get="{% url 'parks:suggest_parks' %}"
|
||||||
hx-indicator="#search-indicator"
|
hx-target="#search-results"
|
||||||
value="{{ request.GET.search|default:'' }}"
|
hx-trigger="input changed delay:300ms"
|
||||||
aria-label="Search parks">
|
hx-indicator="#search-indicator"
|
||||||
<div class="absolute inset-y-0 right-0 flex items-center pr-3">
|
x-ref="searchForm">
|
||||||
<div id="search-indicator" class="htmx-indicator">
|
<div class="relative">
|
||||||
<svg class="h-5 w-5 text-gray-400 animate-spin" viewBox="0 0 24 24" aria-hidden="true">
|
<input type="search"
|
||||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none"/>
|
name="search"
|
||||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
|
placeholder="Search parks..."
|
||||||
</svg>
|
class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||||
|
aria-label="Search parks"
|
||||||
|
aria-controls="search-results"
|
||||||
|
:aria-expanded="query !== ''"
|
||||||
|
x-model="query"
|
||||||
|
@keydown.escape="query = ''">
|
||||||
|
|
||||||
|
<!-- Loading indicator -->
|
||||||
|
<div id="search-indicator"
|
||||||
|
class="htmx-indicator absolute right-3 top-1/2 -translate-y-1/2"
|
||||||
|
role="status"
|
||||||
|
aria-label="Loading search results">
|
||||||
|
<svg class="w-5 h-5 text-gray-400 animate-spin" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none"/>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
|
||||||
|
</svg>
|
||||||
|
<span class="sr-only">Searching...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<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>
|
||||||
@@ -72,12 +97,14 @@
|
|||||||
<div class="bg-white shadow sm:rounded-lg">
|
<div class="bg-white shadow sm:rounded-lg">
|
||||||
<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"
|
||||||
hx-get="{% url 'parks:park_list' %}"
|
x-ref="filterForm"
|
||||||
|
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>
|
||||||
@@ -91,8 +118,4 @@
|
|||||||
data-view-mode="{{ view_mode|default:'grid' }}">
|
data-view-mode="{{ view_mode|default:'grid' }}">
|
||||||
{% 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 %}
|
|
||||||
|
|
||||||
{% block extra_js %}
|
|
||||||
<script src="{% static 'parks/js/search.js' %}"></script>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -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">
|
||||||
|
{{ park.name }}
|
||||||
<a href="{% url 'parks:park_detail' park.slug %}"
|
</a>
|
||||||
class="absolute inset-0 z-0"
|
</h2>
|
||||||
aria-label="View details for {{ park.name }}"></a>
|
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
<div class="relative z-10 {% if view_mode == 'grid' %}aspect-video{% endif %}">
|
<span class="status-badge status-{{ park.status|lower }}">
|
||||||
{% if park.photos.exists %}
|
{{ park.get_status_display }}
|
||||||
<img src="{{ park.photos.first.image.url }}"
|
</span>
|
||||||
alt="Photo of {{ park.name }}"
|
</div>
|
||||||
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">
|
{% if park.owner %}
|
||||||
{% else %}
|
<div class="mt-4 text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300">
|
||||||
<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 %}"
|
<a href="{% url 'companies:company_detail' park.owner.slug %}">
|
||||||
role="img"
|
{{ park.owner.name }}
|
||||||
aria-label="Park initial letter">
|
</a>
|
||||||
<span class="text-2xl font-medium text-gray-400">{{ park.name|first|upper }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
</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 }}
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div class="mt-1 text-sm text-gray-500 truncate">
|
|
||||||
{% with location=park.location.first %}
|
|
||||||
{% 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 }}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{% if park.opening_date %}
|
|
||||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800"
|
|
||||||
data-testid="park-opening-date">
|
|
||||||
Opened {{ park.opening_date|date:"Y" }}
|
|
||||||
</span>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% 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 %}
|
|
||||||
</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 %}
|
||||||
|
|||||||
30
parks/templates/parks/partials/park_suggestions.html
Normal file
30
parks/templates/parks/partials/park_suggestions.html
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% if parks %}
|
||||||
|
<div class="py-2">
|
||||||
|
{% for park in parks %}
|
||||||
|
<button class="w-full text-left px-4 py-2 hover:bg-gray-100 focus:bg-gray-100 focus:outline-none dark:hover:bg-gray-700 dark:focus:bg-gray-700"
|
||||||
|
role="option"
|
||||||
|
@click="$dispatch('search-selected', '{{ park.name }}')"
|
||||||
|
value="{{ park.id }}">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<div class="font-medium">{{ park.name }}</div>
|
||||||
|
<div class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{% if park.formatted_location %}
|
||||||
|
{{ park.formatted_location }}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{{ park.get_status_display }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="px-4 py-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
No parks found matching "{{ query }}"
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
37
parks/templates/parks/partials/search_suggestions.html
Normal file
37
parks/templates/parks/partials/search_suggestions.html
Normal 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
127
parks/tests/README.md
Normal 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
183
parks/tests/test_search.py
Normal 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']
|
||||||
@@ -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
|
||||||
|
|||||||
551
parks/views.py
551
parks/views.py
@@ -1,61 +1,126 @@
|
|||||||
|
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')
|
||||||
if not park_id:
|
if not park_id:
|
||||||
return HttpResponse('<option value="">Select a park first</option>')
|
return HttpResponse('<option value="">Select a park first</option>')
|
||||||
|
|
||||||
try:
|
try:
|
||||||
park = Park.objects.get(id=park_id)
|
park = Park.objects.get(id=park_id)
|
||||||
areas = park.areas.all()
|
areas = park.areas.all()
|
||||||
@@ -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:
|
||||||
@@ -154,11 +222,11 @@ class ParkListView(HTMXFilterableMixin, ListView):
|
|||||||
if self.request.htmx:
|
if self.request.htmx:
|
||||||
return ["parks/partials/park_list_item.html"]
|
return ["parks/partials/park_list_item.html"]
|
||||||
return [self.template_name]
|
return [self.template_name]
|
||||||
|
|
||||||
def get_view_mode(self) -> ViewMode:
|
def get_view_mode(self) -> ViewMode:
|
||||||
"""Get the current view mode (grid or list)"""
|
"""Get the current view mode (grid or list)"""
|
||||||
return get_view_mode(self.request)
|
return get_view_mode(self.request)
|
||||||
|
|
||||||
def get_queryset(self) -> QuerySet[Park]:
|
def get_queryset(self) -> QuerySet[Park]:
|
||||||
"""Get base queryset with annotations and apply filters"""
|
"""Get base queryset with annotations and apply filters"""
|
||||||
try:
|
try:
|
||||||
@@ -166,7 +234,7 @@ class ParkListView(HTMXFilterableMixin, ListView):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
messages.error(self.request, f"Error loading parks: {str(e)}")
|
messages.error(self.request, f"Error loading parks: {str(e)}")
|
||||||
queryset = self.model.objects.none()
|
queryset = self.model.objects.none()
|
||||||
|
|
||||||
# Always initialize filterset, even if queryset failed
|
# Always initialize filterset, even if queryset failed
|
||||||
self.filterset = self.get_filter_class()(self.request.GET, queryset=queryset)
|
self.filterset = self.get_filter_class()(self.request.GET, queryset=queryset)
|
||||||
return self.filterset.qs
|
return self.filterset.qs
|
||||||
@@ -180,7 +248,7 @@ class ParkListView(HTMXFilterableMixin, ListView):
|
|||||||
self.request.GET,
|
self.request.GET,
|
||||||
queryset=self.model.objects.none()
|
queryset=self.model.objects.none()
|
||||||
)
|
)
|
||||||
|
|
||||||
context = super().get_context_data(**kwargs)
|
context = super().get_context_data(**kwargs)
|
||||||
context.update({
|
context.update({
|
||||||
'view_mode': self.get_view_mode(),
|
'view_mode': self.get_view_mode(),
|
||||||
@@ -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
54
parks/views_search.py
Normal 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})
|
||||||
@@ -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",
|
||||||
]
|
]
|
||||||
|
|||||||
20
reviews/migrations/0002_alter_review_id.py
Normal file
20
reviews/migrations/0002_alter_review_id.py
Normal 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"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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")},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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">
|
||||||
<h3 class="text-sm font-medium text-blue-800">Active Filters</h3>
|
<div>
|
||||||
|
<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>
|
||||||
@@ -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
42
static/js/search.js
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
function parkSearch() {
|
||||||
|
return {
|
||||||
|
query: '',
|
||||||
|
results: [],
|
||||||
|
loading: false,
|
||||||
|
selectedId: null,
|
||||||
|
|
||||||
|
async search() {
|
||||||
|
if (!this.query.trim()) {
|
||||||
|
this.results = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.loading = true;
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/parks/suggest_parks/?search=${encodeURIComponent(this.query)}`);
|
||||||
|
const data = await response.json();
|
||||||
|
this.results = data.results;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Search failed:', error);
|
||||||
|
this.results = [];
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
clear() {
|
||||||
|
this.query = '';
|
||||||
|
this.results = [];
|
||||||
|
this.selectedId = null;
|
||||||
|
},
|
||||||
|
|
||||||
|
selectPark(park) {
|
||||||
|
this.query = park.name;
|
||||||
|
this.selectedId = park.id;
|
||||||
|
this.results = [];
|
||||||
|
|
||||||
|
// Trigger filter update
|
||||||
|
document.getElementById('park-filters').dispatchEvent(new Event('change'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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) || ''
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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
15
uv.lock
generated
@@ -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" },
|
||||||
|
|||||||
Reference in New Issue
Block a user