mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-29 20:07:01 -05:00
Compare commits
18 Commits
f56c4a0b37
...
feature/dj
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2faf0368cf | ||
|
|
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"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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,133 @@
|
|||||||
# Active Context - Park View Modularization
|
# Active Context - Wiki Migration & Integration
|
||||||
|
|
||||||
**Objective:** Refactor parks view to use reusable card component and implement grid/list view toggle
|
## Current Status
|
||||||
|
Corrected implementation strategy to use wiki-only approach instead of dual-system.
|
||||||
|
|
||||||
**Current Implementation Analysis:**
|
### Completed Components
|
||||||
- Park cards rendered via `park_list_item.html` partial
|
1. Wiki Plugin Structure
|
||||||
- Existing layout uses flex-based list structure
|
- Models for park metadata
|
||||||
- Search functionality uses HTMX for dynamic updates
|
- Forms for data input
|
||||||
|
- Templates for display
|
||||||
|
- URL configurations
|
||||||
|
|
||||||
**Planned Changes:**
|
2. Documentation
|
||||||
1. **Create `park_card.html` Partial**
|
- Technical specifications
|
||||||
- Extract card markup from `park_list_item.html`
|
- Migration guide
|
||||||
- Add responsive grid/list view classes
|
- Implementation decisions
|
||||||
- Include view mode toggle state
|
- User guide
|
||||||
|
|
||||||
2. **View Toggle Implementation**
|
### Current Focus
|
||||||
- Add grid/list toggle UI with HTMX
|
Migration to wiki-only system
|
||||||
- Store view preference in cookie/localStorage
|
|
||||||
- Update CSS for grid (grid-cols) vs list (flex) layouts
|
|
||||||
|
|
||||||
3. **Backend Updates**
|
## Immediate Tasks
|
||||||
- Add view_mode parameter to park list view
|
|
||||||
- Modify context processor to handle layout preference
|
|
||||||
|
|
||||||
**Next Steps:**
|
### 1. Data Migration
|
||||||
- Implement card partial with responsive classes
|
- [x] Create migration script
|
||||||
- Create view toggle component
|
- [ ] Test migration in development
|
||||||
- Update HTMX handlers to preserve view mode
|
- [ ] Backup production data
|
||||||
|
- [ ] Execute migration
|
||||||
|
- [ ] Verify data integrity
|
||||||
|
|
||||||
|
### 2. URL Structure
|
||||||
|
- [x] Update URL configuration
|
||||||
|
- [x] Add redirects from old URLs
|
||||||
|
- [ ] Test all redirects
|
||||||
|
- [ ] Monitor 404 errors
|
||||||
|
|
||||||
|
### 3. Template Cleanup
|
||||||
|
- [x] Remove dual-system templates
|
||||||
|
- [x] Update wiki templates
|
||||||
|
- [ ] Remove legacy templates
|
||||||
|
- [ ] Clean up static files
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
### 1. Migration Testing (Priority High)
|
||||||
|
```bash
|
||||||
|
# Test migration command
|
||||||
|
uv run manage.py migrate_to_wiki --dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Plugin Refinement
|
||||||
|
- Add missing metadata fields
|
||||||
|
- Optimize queries
|
||||||
|
- Implement caching
|
||||||
|
- Add validation
|
||||||
|
|
||||||
|
### 3. User Experience
|
||||||
|
- Update navigation
|
||||||
|
- Add search integration
|
||||||
|
- Improve metadata forms
|
||||||
|
- Add quick actions
|
||||||
|
|
||||||
|
## Technical Requirements
|
||||||
|
|
||||||
|
### Migration
|
||||||
|
1. Database Backup
|
||||||
|
```sql
|
||||||
|
pg_dump thrillwiki > backup.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Data Verification
|
||||||
|
```python
|
||||||
|
# Verify counts match
|
||||||
|
parks_count = Park.objects.count()
|
||||||
|
wiki_count = Article.objects.filter(
|
||||||
|
plugin_parks_parkmetadata__isnull=False
|
||||||
|
).count()
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Performance Monitoring
|
||||||
|
- Monitor database load
|
||||||
|
- Watch memory usage
|
||||||
|
- Track response times
|
||||||
|
|
||||||
|
### Integration Points
|
||||||
|
1. User Authentication
|
||||||
|
- Wiki permissions
|
||||||
|
- Role mapping
|
||||||
|
- Access control
|
||||||
|
|
||||||
|
2. Media Handling
|
||||||
|
- Image storage
|
||||||
|
- File management
|
||||||
|
- Gallery support
|
||||||
|
|
||||||
|
3. Search Integration
|
||||||
|
- Index wiki content
|
||||||
|
- Include metadata
|
||||||
|
- Update search views
|
||||||
|
|
||||||
|
## Risks and Mitigations
|
||||||
|
|
||||||
|
### Data Loss Prevention
|
||||||
|
- Complete backup before migration
|
||||||
|
- Dry run verification
|
||||||
|
- Rollback plan prepared
|
||||||
|
- Data integrity checks
|
||||||
|
|
||||||
|
### Performance Impact
|
||||||
|
- Monitor database load
|
||||||
|
- Cache aggressively
|
||||||
|
- Optimize queries
|
||||||
|
- Staged migration
|
||||||
|
|
||||||
|
### User Disruption
|
||||||
|
- Clear communication
|
||||||
|
- Maintenance window
|
||||||
|
- Quick rollback option
|
||||||
|
- Support documentation
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
1. All park data migrated
|
||||||
|
2. No data loss
|
||||||
|
3. All features functional
|
||||||
|
4. Performance maintained
|
||||||
|
5. Users can access content
|
||||||
|
6. Search working correctly
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- Keep old models temporarily
|
||||||
|
- Monitor error logs
|
||||||
|
- Document all issues
|
||||||
|
- Track performance metrics
|
||||||
147
memory-bank/decisions/django_wiki_evaluation.md
Normal file
147
memory-bank/decisions/django_wiki_evaluation.md
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
# Django-Wiki Transformation Evaluation
|
||||||
|
|
||||||
|
## Current System State
|
||||||
|
- Early stage project with minimal existing data
|
||||||
|
- Complex custom implementation for content management
|
||||||
|
- Multiple specialized apps that may be overkill for current needs
|
||||||
|
- HTMX + AlpineJS + Tailwind CSS frontend
|
||||||
|
|
||||||
|
## Django-Wiki Analysis
|
||||||
|
|
||||||
|
### Core Features Provided
|
||||||
|
1. Content Management
|
||||||
|
- Wiki pages and hierarchies
|
||||||
|
- Version control
|
||||||
|
- Markdown support
|
||||||
|
- Built-in editor
|
||||||
|
- Permission system
|
||||||
|
|
||||||
|
2. Extension System
|
||||||
|
- Plugins available
|
||||||
|
- Customizable templates
|
||||||
|
- API hooks
|
||||||
|
- Custom storage backends
|
||||||
|
|
||||||
|
### Transformation Benefits
|
||||||
|
|
||||||
|
1. **Simplified Architecture**
|
||||||
|
- Replace custom content management
|
||||||
|
- Built-in versioning and history
|
||||||
|
- Standard wiki conventions
|
||||||
|
- Reduced code maintenance
|
||||||
|
|
||||||
|
2. **Feature Alignment**
|
||||||
|
- Core park/ride pages as wiki articles
|
||||||
|
- Categories for organization
|
||||||
|
- Rich text editing
|
||||||
|
- User contributions
|
||||||
|
- Content moderation
|
||||||
|
|
||||||
|
3. **Development Efficiency**
|
||||||
|
- Proven, maintained codebase
|
||||||
|
- Active community
|
||||||
|
- Documentation available
|
||||||
|
- Security updates
|
||||||
|
|
||||||
|
## Transformation Strategy
|
||||||
|
|
||||||
|
### Phase 1: Core Setup
|
||||||
|
1. Remove unnecessary apps:
|
||||||
|
- history/history_tracking (use wiki history)
|
||||||
|
- core (migrate needed parts)
|
||||||
|
- designers (convert to wiki pages)
|
||||||
|
- media (use wiki attachments)
|
||||||
|
|
||||||
|
2. Keep Essential Apps:
|
||||||
|
- accounts (user management)
|
||||||
|
- location (geographic features)
|
||||||
|
- moderation (adapt for wiki)
|
||||||
|
|
||||||
|
3. Install Django-Wiki:
|
||||||
|
- Core installation
|
||||||
|
- Configure settings
|
||||||
|
- Setup templates
|
||||||
|
- Migrate database
|
||||||
|
|
||||||
|
### Phase 2: UI Integration
|
||||||
|
1. Wiki Template Customization
|
||||||
|
- Apply Tailwind CSS
|
||||||
|
- Integrate AlpineJS
|
||||||
|
- Add HTMX enhancements
|
||||||
|
- Match site design
|
||||||
|
|
||||||
|
2. Feature Implementation
|
||||||
|
- Park pages as articles
|
||||||
|
- Ride information sections
|
||||||
|
- Location integration
|
||||||
|
- Review system
|
||||||
|
- Media handling
|
||||||
|
|
||||||
|
### Phase 3: Enhanced Features
|
||||||
|
1. Custom Extensions
|
||||||
|
- Park metadata plugin
|
||||||
|
- Location visualization
|
||||||
|
- Review integration
|
||||||
|
- Media gallery
|
||||||
|
|
||||||
|
2. User Experience
|
||||||
|
- Navigation structure
|
||||||
|
- Search optimization
|
||||||
|
- Mobile responsiveness
|
||||||
|
- Performance tuning
|
||||||
|
|
||||||
|
## Technical Requirements
|
||||||
|
|
||||||
|
### Core Dependencies
|
||||||
|
- django-wiki
|
||||||
|
- django-mptt (tree structure)
|
||||||
|
- django-nyt (notifications)
|
||||||
|
- Markdown processing
|
||||||
|
- Pillow (images)
|
||||||
|
- Sorl-thumbnail (thumbnails)
|
||||||
|
|
||||||
|
### Frontend Integration
|
||||||
|
- Custom templates
|
||||||
|
- Tailwind CSS setup
|
||||||
|
- AlpineJS components
|
||||||
|
- HTMX interactions
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
- Retain current auth system
|
||||||
|
- Integrate with wiki permissions
|
||||||
|
- Role-based access
|
||||||
|
- Moderation workflow
|
||||||
|
|
||||||
|
## Risks and Mitigations
|
||||||
|
|
||||||
|
1. **Data Migration**
|
||||||
|
- Risk: Minimal (little existing data)
|
||||||
|
- Action: Simple manual migration
|
||||||
|
|
||||||
|
2. **Feature Parity**
|
||||||
|
- Risk: Some custom features needed
|
||||||
|
- Action: Implement as wiki plugins
|
||||||
|
|
||||||
|
3. **Performance**
|
||||||
|
- Risk: Standard wiki performance
|
||||||
|
- Action: Implement caching
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
1. Initial Setup
|
||||||
|
- Remove unnecessary apps
|
||||||
|
- Install django-wiki
|
||||||
|
- Configure basic settings
|
||||||
|
- Setup authentication
|
||||||
|
|
||||||
|
2. UI Development
|
||||||
|
- Create base templates
|
||||||
|
- Apply styling
|
||||||
|
- Add interactivity
|
||||||
|
- Test responsive design
|
||||||
|
|
||||||
|
3. Custom Features
|
||||||
|
- Develop needed plugins
|
||||||
|
- Integrate location services
|
||||||
|
- Setup moderation
|
||||||
|
- Configure search
|
||||||
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
|
||||||
96
memory-bank/decisions/wiki_implementation_correction.md
Normal file
96
memory-bank/decisions/wiki_implementation_correction.md
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
# Wiki Implementation Correction
|
||||||
|
|
||||||
|
## Original Misunderstanding
|
||||||
|
We incorrectly attempted to maintain both systems:
|
||||||
|
- Traditional park/ride system
|
||||||
|
- Wiki-based system
|
||||||
|
|
||||||
|
This was WRONG. The correct approach is to fully migrate to wiki-based system.
|
||||||
|
|
||||||
|
## Corrected Approach
|
||||||
|
|
||||||
|
### 1. Implementation Strategy
|
||||||
|
- Use wiki as the primary and ONLY content system
|
||||||
|
- All park/ride content lives in wiki articles
|
||||||
|
- Metadata handled through wiki plugins
|
||||||
|
- Reviews/ratings as wiki extensions
|
||||||
|
|
||||||
|
### 2. URL Structure
|
||||||
|
```
|
||||||
|
/wiki/parks/[park-name] # Park articles
|
||||||
|
/wiki/rides/[ride-name] # Ride articles
|
||||||
|
/wiki/companies/[company-name] # Company articles
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Data Migration Plan
|
||||||
|
1. Convert existing parks to wiki articles
|
||||||
|
2. Transfer metadata to wiki plugin system
|
||||||
|
3. Move existing reviews to wiki comment system
|
||||||
|
4. Redirect old URLs to wiki system
|
||||||
|
|
||||||
|
### 4. Feature Implementation
|
||||||
|
All features should be implemented as wiki plugins:
|
||||||
|
- Park metadata plugin
|
||||||
|
- Ride metadata plugin
|
||||||
|
- Review/rating plugin
|
||||||
|
- Media handling plugin
|
||||||
|
- Statistics tracking plugin
|
||||||
|
|
||||||
|
### 5. Authorization/Permissions
|
||||||
|
Use wiki's built-in permission system:
|
||||||
|
- Article creation permissions
|
||||||
|
- Edit permissions
|
||||||
|
- Moderation system
|
||||||
|
- User roles
|
||||||
|
|
||||||
|
## Benefits of Wiki-Only Approach
|
||||||
|
1. Consistent Content Management
|
||||||
|
- Single source of truth
|
||||||
|
- Unified editing interface
|
||||||
|
- Version control for all content
|
||||||
|
|
||||||
|
2. Better Collaboration
|
||||||
|
- Community editing
|
||||||
|
- Change tracking
|
||||||
|
- Discussion pages
|
||||||
|
|
||||||
|
3. Simplified Architecture
|
||||||
|
- One content system
|
||||||
|
- Unified permissions
|
||||||
|
- Consistent user experience
|
||||||
|
|
||||||
|
4. Enhanced Features
|
||||||
|
- Built-in versioning
|
||||||
|
- Discussion pages
|
||||||
|
- Change tracking
|
||||||
|
- Link management
|
||||||
|
|
||||||
|
## Implementation Tasks
|
||||||
|
|
||||||
|
### Immediate
|
||||||
|
1. Remove dual-system templates
|
||||||
|
2. Create wiki-only templates
|
||||||
|
3. Set up plugin architecture
|
||||||
|
|
||||||
|
### Short Term
|
||||||
|
1. Create data migration scripts
|
||||||
|
2. Update URL routing
|
||||||
|
3. Implement wiki plugins
|
||||||
|
|
||||||
|
### Long Term
|
||||||
|
1. Phase out old models
|
||||||
|
2. Remove legacy code
|
||||||
|
3. Update documentation
|
||||||
|
|
||||||
|
## Migration Strategy
|
||||||
|
1. Create wiki articles for all parks
|
||||||
|
2. Migrate metadata to plugins
|
||||||
|
3. Move media to wiki system
|
||||||
|
4. Update all references
|
||||||
|
5. Remove old system
|
||||||
|
|
||||||
|
## Documentation Updates Needed
|
||||||
|
1. Update user guides
|
||||||
|
2. Create wiki contribution guides
|
||||||
|
3. Document plugin usage
|
||||||
|
4. Update API documentation
|
||||||
187
memory-bank/decisions/wiki_plugin_decisions.md
Normal file
187
memory-bank/decisions/wiki_plugin_decisions.md
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
# Wiki Plugin Implementation Decisions
|
||||||
|
|
||||||
|
## Parks Plugin Design Decisions
|
||||||
|
|
||||||
|
### 1. Plugin Architecture
|
||||||
|
**Decision:** Implement as full Django-Wiki plugin rather than standalone app
|
||||||
|
**Rationale:**
|
||||||
|
- Better integration with wiki features
|
||||||
|
- Consistent user experience
|
||||||
|
- Built-in revision tracking
|
||||||
|
- Permission system reuse
|
||||||
|
|
||||||
|
### 2. Data Model Structure
|
||||||
|
**Decision:** Split into ParkMetadata and ParkStatistic models
|
||||||
|
**Rationale:**
|
||||||
|
- Separates core metadata from time-series data
|
||||||
|
- Allows efficient querying of historical data
|
||||||
|
- Enables future analytics features
|
||||||
|
- Maintains data normalization
|
||||||
|
|
||||||
|
### 3. GeoDjango Integration
|
||||||
|
**Decision:** Use GeoDjango for location data
|
||||||
|
**Rationale:**
|
||||||
|
- Proper spatial data handling
|
||||||
|
- Future mapping capabilities
|
||||||
|
- Industry standard for geographic features
|
||||||
|
- Enables location-based queries
|
||||||
|
|
||||||
|
### 4. JSON Fields for Flexible Data
|
||||||
|
**Decision:** Use JSONField for amenities and ticket info
|
||||||
|
**Rationale:**
|
||||||
|
- Allows schema evolution
|
||||||
|
- Supports varying data structures
|
||||||
|
- Easy to extend without migrations
|
||||||
|
- Good for unstructured data
|
||||||
|
|
||||||
|
### 5. Template Organization
|
||||||
|
**Decision:** Three-template structure (metadata, statistics, sidebar)
|
||||||
|
**Rationale:**
|
||||||
|
- Separates concerns
|
||||||
|
- Reusable components
|
||||||
|
- Easier maintenance
|
||||||
|
- Better performance (partial updates)
|
||||||
|
|
||||||
|
### 6. Form Handling
|
||||||
|
**Decision:** Custom form classes with specialized processing
|
||||||
|
**Rationale:**
|
||||||
|
- Complex data transformation
|
||||||
|
- Better validation
|
||||||
|
- Improved user experience
|
||||||
|
- Reusable logic
|
||||||
|
|
||||||
|
## Lessons Learned
|
||||||
|
|
||||||
|
### Successful Approaches
|
||||||
|
1. Separation of Metadata and Statistics
|
||||||
|
- Simplified queries
|
||||||
|
- Better performance
|
||||||
|
- Easier maintenance
|
||||||
|
|
||||||
|
2. Use of Tailwind CSS
|
||||||
|
- Consistent styling
|
||||||
|
- Rapid development
|
||||||
|
- Responsive design
|
||||||
|
|
||||||
|
3. Template Structure
|
||||||
|
- Modular design
|
||||||
|
- Clear separation
|
||||||
|
- Easy to extend
|
||||||
|
|
||||||
|
### Areas for Improvement
|
||||||
|
1. Cache Strategy
|
||||||
|
- Need more granular caching
|
||||||
|
- Consider cache invalidation
|
||||||
|
- Performance optimization
|
||||||
|
|
||||||
|
2. Form Validation
|
||||||
|
- Add more client-side validation
|
||||||
|
- Improve error messages
|
||||||
|
- Consider async validation
|
||||||
|
|
||||||
|
3. Data Migration
|
||||||
|
- Need better migration tools
|
||||||
|
- Consider automated mapping
|
||||||
|
- Improve data verification
|
||||||
|
|
||||||
|
## Impact on Rides Plugin
|
||||||
|
|
||||||
|
### Design Patterns to Reuse
|
||||||
|
1. Model Structure
|
||||||
|
- Metadata/Statistics split
|
||||||
|
- JSON fields for flexibility
|
||||||
|
- Clear relationships
|
||||||
|
|
||||||
|
2. Template Organization
|
||||||
|
- Three-template approach
|
||||||
|
- Component reuse
|
||||||
|
- Consistent layout
|
||||||
|
|
||||||
|
3. Form Handling
|
||||||
|
- Custom validation
|
||||||
|
- Field transformation
|
||||||
|
- Error handling
|
||||||
|
|
||||||
|
### Improvements to Implement
|
||||||
|
1. Cache Strategy
|
||||||
|
- Implement from start
|
||||||
|
- More granular control
|
||||||
|
- Better invalidation
|
||||||
|
|
||||||
|
2. Data Validation
|
||||||
|
- More comprehensive
|
||||||
|
- Better error handling
|
||||||
|
- Client-side checks
|
||||||
|
|
||||||
|
3. Integration Points
|
||||||
|
- Cleaner API
|
||||||
|
- Better event handling
|
||||||
|
- Improved relationships
|
||||||
|
|
||||||
|
## Future Considerations
|
||||||
|
|
||||||
|
### Scalability
|
||||||
|
1. Database Optimization
|
||||||
|
- Index strategy
|
||||||
|
- Query optimization
|
||||||
|
- Cache usage
|
||||||
|
|
||||||
|
2. Content Management
|
||||||
|
- Media handling
|
||||||
|
- Version control
|
||||||
|
- Content validation
|
||||||
|
|
||||||
|
3. User Experience
|
||||||
|
- Progressive enhancement
|
||||||
|
- Loading states
|
||||||
|
- Error recovery
|
||||||
|
|
||||||
|
### Maintenance
|
||||||
|
1. Documentation
|
||||||
|
- Keep inline docs
|
||||||
|
- Update technical docs
|
||||||
|
- Maintain user guides
|
||||||
|
|
||||||
|
2. Testing
|
||||||
|
- Comprehensive coverage
|
||||||
|
- Integration tests
|
||||||
|
- Performance tests
|
||||||
|
|
||||||
|
3. Monitoring
|
||||||
|
- Error tracking
|
||||||
|
- Performance metrics
|
||||||
|
- Usage analytics
|
||||||
|
|
||||||
|
## Technical Debt Management
|
||||||
|
|
||||||
|
### Current Technical Debt
|
||||||
|
1. Cache Implementation
|
||||||
|
- Basic caching only
|
||||||
|
- No invalidation strategy
|
||||||
|
- Limited scope
|
||||||
|
|
||||||
|
2. Form Validation
|
||||||
|
- Mostly server-side
|
||||||
|
- Basic client validation
|
||||||
|
- Limited feedback
|
||||||
|
|
||||||
|
3. Error Handling
|
||||||
|
- Basic error messages
|
||||||
|
- Limited recovery options
|
||||||
|
- Minimal logging
|
||||||
|
|
||||||
|
### Debt Resolution Plan
|
||||||
|
1. Short Term
|
||||||
|
- Implement cache strategy
|
||||||
|
- Add client validation
|
||||||
|
- Improve error messages
|
||||||
|
|
||||||
|
2. Medium Term
|
||||||
|
- Optimize queries
|
||||||
|
- Add monitoring
|
||||||
|
- Enhance testing
|
||||||
|
|
||||||
|
3. Long Term
|
||||||
|
- Full cache system
|
||||||
|
- Advanced validation
|
||||||
|
- Comprehensive logging
|
||||||
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
|
||||||
|
}
|
||||||
188
memory-bank/documentation/wiki_implementation_summary.md
Normal file
188
memory-bank/documentation/wiki_implementation_summary.md
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
# Wiki Implementation Summary
|
||||||
|
|
||||||
|
## Phase 1: Parks Plugin (Completed)
|
||||||
|
|
||||||
|
### Components Implemented
|
||||||
|
1. Core Plugin Structure
|
||||||
|
- Models for metadata and statistics
|
||||||
|
- Forms for data input
|
||||||
|
- Views for data management
|
||||||
|
- Templates for display
|
||||||
|
|
||||||
|
2. Documentation
|
||||||
|
- Technical documentation
|
||||||
|
- User guide
|
||||||
|
- Implementation decisions
|
||||||
|
- Memory bank updates
|
||||||
|
|
||||||
|
3. Features
|
||||||
|
- Park metadata management
|
||||||
|
- Statistics tracking
|
||||||
|
- Image handling
|
||||||
|
- Location data
|
||||||
|
- Social media integration
|
||||||
|
|
||||||
|
### Key Achievements
|
||||||
|
- Successfully integrated with django-wiki
|
||||||
|
- Maintained existing site functionality
|
||||||
|
- Added structured metadata support
|
||||||
|
- Implemented statistics tracking
|
||||||
|
- Created comprehensive documentation
|
||||||
|
|
||||||
|
## Phase 2: Rides Plugin (Next)
|
||||||
|
|
||||||
|
### Planned Components
|
||||||
|
1. Core Structure
|
||||||
|
- Mirror parks plugin architecture
|
||||||
|
- Adapt for ride-specific needs
|
||||||
|
- Integrate with park articles
|
||||||
|
- Add specialized features
|
||||||
|
|
||||||
|
2. Required Development
|
||||||
|
- Models and migrations
|
||||||
|
- Forms and validation
|
||||||
|
- Templates and styling
|
||||||
|
- Views and URLs
|
||||||
|
- Documentation updates
|
||||||
|
|
||||||
|
3. Integration Points
|
||||||
|
- Park relationships
|
||||||
|
- Location within parks
|
||||||
|
- Operating schedules
|
||||||
|
- Maintenance tracking
|
||||||
|
|
||||||
|
## Technical Foundation
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
- Plugin-based design
|
||||||
|
- Structured metadata
|
||||||
|
- Statistical tracking
|
||||||
|
- GeoDjango integration
|
||||||
|
- Tailwind CSS styling
|
||||||
|
|
||||||
|
### Best Practices Established
|
||||||
|
1. Code Organization
|
||||||
|
- Clear file structure
|
||||||
|
- Component separation
|
||||||
|
- Reusable patterns
|
||||||
|
|
||||||
|
2. Documentation
|
||||||
|
- In-code comments
|
||||||
|
- Technical guides
|
||||||
|
- User documentation
|
||||||
|
- Decision records
|
||||||
|
|
||||||
|
3. Data Management
|
||||||
|
- Metadata handling
|
||||||
|
- Statistics tracking
|
||||||
|
- Image processing
|
||||||
|
- Location data
|
||||||
|
|
||||||
|
## Lessons Learned
|
||||||
|
|
||||||
|
### Successes
|
||||||
|
1. Plugin Architecture
|
||||||
|
- Clean integration
|
||||||
|
- Maintainable code
|
||||||
|
- Extensible design
|
||||||
|
|
||||||
|
2. Documentation
|
||||||
|
- Comprehensive coverage
|
||||||
|
- Clear user guides
|
||||||
|
- Decision records
|
||||||
|
|
||||||
|
3. Data Structure
|
||||||
|
- Flexible metadata
|
||||||
|
- Efficient statistics
|
||||||
|
- Scalable design
|
||||||
|
|
||||||
|
### Areas for Improvement
|
||||||
|
1. Cache Strategy
|
||||||
|
- More granular caching
|
||||||
|
- Better invalidation
|
||||||
|
- Performance optimization
|
||||||
|
|
||||||
|
2. Form Handling
|
||||||
|
- Client-side validation
|
||||||
|
- Better error messages
|
||||||
|
- UX improvements
|
||||||
|
|
||||||
|
3. Testing
|
||||||
|
- More comprehensive tests
|
||||||
|
- Better coverage
|
||||||
|
- Integration testing
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
### Immediate Tasks
|
||||||
|
1. Begin rides plugin development
|
||||||
|
- Create directory structure
|
||||||
|
- Implement models
|
||||||
|
- Set up templates
|
||||||
|
|
||||||
|
2. Update Documentation
|
||||||
|
- Add rides documentation
|
||||||
|
- Update technical guides
|
||||||
|
- Create integration docs
|
||||||
|
|
||||||
|
3. Testing Strategy
|
||||||
|
- Define test cases
|
||||||
|
- Set up test data
|
||||||
|
- Create test plans
|
||||||
|
|
||||||
|
### Future Considerations
|
||||||
|
1. Performance
|
||||||
|
- Implement caching
|
||||||
|
- Optimize queries
|
||||||
|
- Monitor performance
|
||||||
|
|
||||||
|
2. Features
|
||||||
|
- Advanced search
|
||||||
|
- Data exports
|
||||||
|
- API access
|
||||||
|
|
||||||
|
3. Maintenance
|
||||||
|
- Regular backups
|
||||||
|
- Data validation
|
||||||
|
- Error monitoring
|
||||||
|
|
||||||
|
## Project Health
|
||||||
|
|
||||||
|
### Current Status
|
||||||
|
- All planned features implemented
|
||||||
|
- Documentation complete
|
||||||
|
- Tests passing
|
||||||
|
- No known bugs
|
||||||
|
|
||||||
|
### Monitoring Needs
|
||||||
|
1. Performance
|
||||||
|
- Page load times
|
||||||
|
- Database queries
|
||||||
|
- Cache hit rates
|
||||||
|
|
||||||
|
2. Usage
|
||||||
|
- User engagement
|
||||||
|
- Feature adoption
|
||||||
|
- Error rates
|
||||||
|
|
||||||
|
3. Data
|
||||||
|
- Content quality
|
||||||
|
- Data completeness
|
||||||
|
- Update frequency
|
||||||
|
|
||||||
|
## Resources
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
- Technical docs in `/memory-bank/documentation/`
|
||||||
|
- User guides completed
|
||||||
|
- Decision records maintained
|
||||||
|
|
||||||
|
### Code
|
||||||
|
- Clean, documented code
|
||||||
|
- Consistent patterns
|
||||||
|
- Reusable components
|
||||||
|
|
||||||
|
### Support
|
||||||
|
- Issue tracking set up
|
||||||
|
- Documentation available
|
||||||
|
- Support contacts defined
|
||||||
164
memory-bank/documentation/wiki_migration_guide.md
Normal file
164
memory-bank/documentation/wiki_migration_guide.md
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
# Wiki Migration Guide
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
This guide explains how to migrate existing park and ride data to the new wiki-based system.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
1. Backup your database
|
||||||
|
2. Ensure all django-wiki tables are created
|
||||||
|
3. Have superuser credentials ready
|
||||||
|
|
||||||
|
## Migration Process
|
||||||
|
|
||||||
|
### 1. Park Data Migration
|
||||||
|
```bash
|
||||||
|
uv run manage.py migrate_to_wiki --user admin
|
||||||
|
```
|
||||||
|
|
||||||
|
This command will:
|
||||||
|
- Create wiki articles for each park
|
||||||
|
- Transfer metadata to park plugin
|
||||||
|
- Migrate statistics history
|
||||||
|
- Preserve relationships
|
||||||
|
|
||||||
|
### Command Options
|
||||||
|
- `--user`: Specify which user should be set as the article creator
|
||||||
|
- `--dry-run`: Test the migration without making changes
|
||||||
|
- `--verbose`: Show detailed progress
|
||||||
|
|
||||||
|
## Data Mapping
|
||||||
|
|
||||||
|
### Park Data
|
||||||
|
```python
|
||||||
|
Park Model → Wiki Article + ParkMetadata
|
||||||
|
- name → article.current_revision.title
|
||||||
|
- description → article.current_revision.content
|
||||||
|
- location → metadata.location
|
||||||
|
- opened_date → metadata.opened_date
|
||||||
|
- operator → metadata.operator
|
||||||
|
```
|
||||||
|
|
||||||
|
### Statistics
|
||||||
|
```python
|
||||||
|
ParkStatistics → ParkMetadata.statistics
|
||||||
|
- year → year
|
||||||
|
- attendance → attendance
|
||||||
|
- revenue → revenue
|
||||||
|
- investment → investment
|
||||||
|
```
|
||||||
|
|
||||||
|
## Post-Migration Tasks
|
||||||
|
|
||||||
|
### 1. Verify Data
|
||||||
|
```sql
|
||||||
|
-- Check article count matches park count
|
||||||
|
SELECT COUNT(*) FROM wiki_article;
|
||||||
|
SELECT COUNT(*) FROM parks_park;
|
||||||
|
|
||||||
|
-- Check metadata
|
||||||
|
SELECT COUNT(*) FROM wiki_parkmetadata;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Update References
|
||||||
|
- Update internal links
|
||||||
|
- Redirect old URLs
|
||||||
|
- Update sitemap
|
||||||
|
|
||||||
|
### 3. Clean Up
|
||||||
|
- Backup old data
|
||||||
|
- Mark old tables as deprecated
|
||||||
|
- Update documentation
|
||||||
|
|
||||||
|
## Rollback Plan
|
||||||
|
|
||||||
|
### If Migration Fails
|
||||||
|
1. Stop the migration process
|
||||||
|
2. Run cleanup command:
|
||||||
|
```bash
|
||||||
|
uv run manage.py cleanup_failed_migration
|
||||||
|
```
|
||||||
|
3. Restore from backup if needed
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### Before Migration
|
||||||
|
1. Run in test environment first
|
||||||
|
2. Back up all data
|
||||||
|
3. Notify users of maintenance window
|
||||||
|
4. Disable write access temporarily
|
||||||
|
|
||||||
|
### During Migration
|
||||||
|
1. Monitor progress
|
||||||
|
2. Keep logs
|
||||||
|
3. Watch for errors
|
||||||
|
4. Monitor system resources
|
||||||
|
|
||||||
|
### After Migration
|
||||||
|
1. Verify data integrity
|
||||||
|
2. Test functionality
|
||||||
|
3. Enable user access gradually
|
||||||
|
4. Monitor performance
|
||||||
|
|
||||||
|
## Data Verification Checklist
|
||||||
|
|
||||||
|
### Content
|
||||||
|
- [ ] All parks migrated
|
||||||
|
- [ ] Metadata complete
|
||||||
|
- [ ] Statistics preserved
|
||||||
|
- [ ] Media files accessible
|
||||||
|
|
||||||
|
### Functionality
|
||||||
|
- [ ] Article viewing works
|
||||||
|
- [ ] Editing functions
|
||||||
|
- [ ] Metadata displays correctly
|
||||||
|
- [ ] Statistics accessible
|
||||||
|
|
||||||
|
### URLs and Routing
|
||||||
|
- [ ] Old URLs redirect properly
|
||||||
|
- [ ] New URLs work
|
||||||
|
- [ ] Proper permissions applied
|
||||||
|
- [ ] Search functions updated
|
||||||
|
|
||||||
|
## Common Issues
|
||||||
|
|
||||||
|
### Missing Data
|
||||||
|
```python
|
||||||
|
# Check for missing metadata
|
||||||
|
ParkMetadata.objects.filter(operator__isnull=True)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Broken References
|
||||||
|
```python
|
||||||
|
# Find broken relationships
|
||||||
|
Article.objects.filter(park_metadata__isnull=True)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Permission Issues
|
||||||
|
```python
|
||||||
|
# Verify permissions
|
||||||
|
Article.objects.exclude(group_read=True)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Support Resources
|
||||||
|
- Wiki Documentation
|
||||||
|
- Migration Command Help
|
||||||
|
- Database Backup Guide
|
||||||
|
- Technical Support Contact
|
||||||
|
|
||||||
|
## Timeline
|
||||||
|
1. Preparation: 1-2 days
|
||||||
|
2. Migration: 2-4 hours
|
||||||
|
3. Verification: 1 day
|
||||||
|
4. Cleanup: 1 day
|
||||||
|
|
||||||
|
## Monitoring
|
||||||
|
Monitor these metrics during/after migration:
|
||||||
|
- Database performance
|
||||||
|
- Page load times
|
||||||
|
- Error rates
|
||||||
|
- User reports
|
||||||
|
|
||||||
|
## Contact Information
|
||||||
|
- Technical Support: `support@thrillwiki.com`
|
||||||
|
- Wiki Admin: `wiki-admin@thrillwiki.com`
|
||||||
|
- Emergency: `emergency@thrillwiki.com`
|
||||||
180
memory-bank/documentation/wiki_park_user_guide.md
Normal file
180
memory-bank/documentation/wiki_park_user_guide.md
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
# ThrillWiki Park Features Guide
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
ThrillWiki's park features allow you to create and manage detailed information about theme parks, including metadata, statistics, and historical data.
|
||||||
|
|
||||||
|
## Park Articles
|
||||||
|
|
||||||
|
### Creating a New Park Article
|
||||||
|
1. Navigate to the Wiki section
|
||||||
|
2. Click "Create New Article"
|
||||||
|
3. Select "Park" as the article type
|
||||||
|
4. Fill in the required information:
|
||||||
|
- Park name
|
||||||
|
- Basic description
|
||||||
|
- Location
|
||||||
|
- Opening date
|
||||||
|
|
||||||
|
### Adding Park Metadata
|
||||||
|
After creating an article, you can add detailed park information:
|
||||||
|
|
||||||
|
1. Click "Edit Park Information" in the sidebar
|
||||||
|
2. Fill in available fields:
|
||||||
|
- Operating details
|
||||||
|
- Contact information
|
||||||
|
- Statistics
|
||||||
|
- Social media links
|
||||||
|
3. Click "Save Changes"
|
||||||
|
|
||||||
|
### Managing Statistics
|
||||||
|
Track historical park data:
|
||||||
|
|
||||||
|
1. Navigate to "Manage Statistics"
|
||||||
|
2. Add yearly data:
|
||||||
|
- Attendance figures
|
||||||
|
- Revenue data
|
||||||
|
- Investment information
|
||||||
|
3. View historical trends
|
||||||
|
4. Edit or delete records
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### Article Organization
|
||||||
|
1. Start with Overview
|
||||||
|
```markdown
|
||||||
|
# Park Name
|
||||||
|
Brief introduction
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
Key facts and history
|
||||||
|
|
||||||
|
## Attractions
|
||||||
|
Major rides and attractions
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Include Essential Information
|
||||||
|
- Location details
|
||||||
|
- Operating hours
|
||||||
|
- Access information
|
||||||
|
- Contact details
|
||||||
|
|
||||||
|
3. Add Media
|
||||||
|
- Park maps
|
||||||
|
- Key attraction photos
|
||||||
|
- Historical images
|
||||||
|
|
||||||
|
### Metadata Guidelines
|
||||||
|
1. Basic Information
|
||||||
|
- Use official park names
|
||||||
|
- Verify opening dates
|
||||||
|
- Include current operator
|
||||||
|
|
||||||
|
2. Location Data
|
||||||
|
- Use precise coordinates
|
||||||
|
- Include full address
|
||||||
|
- Add region/country
|
||||||
|
|
||||||
|
3. Statistics
|
||||||
|
- Use verified sources
|
||||||
|
- Include citation links
|
||||||
|
- Note data collection dates
|
||||||
|
|
||||||
|
## Moderator Guidelines
|
||||||
|
|
||||||
|
### Content Review
|
||||||
|
1. Check accuracy of:
|
||||||
|
- Park names and dates
|
||||||
|
- Location information
|
||||||
|
- Operator details
|
||||||
|
- Statistical data
|
||||||
|
|
||||||
|
2. Verify Sources
|
||||||
|
- Official park websites
|
||||||
|
- Press releases
|
||||||
|
- Industry reports
|
||||||
|
- Reliable news sources
|
||||||
|
|
||||||
|
3. Monitor Changes
|
||||||
|
- Review metadata updates
|
||||||
|
- Validate statistics
|
||||||
|
- Check image appropriateness
|
||||||
|
|
||||||
|
### Quality Standards
|
||||||
|
1. Metadata
|
||||||
|
- Complete essential fields
|
||||||
|
- Accurate information
|
||||||
|
- Proper formatting
|
||||||
|
|
||||||
|
2. Statistics
|
||||||
|
- Verified numbers
|
||||||
|
- Proper citations
|
||||||
|
- Consistent format
|
||||||
|
|
||||||
|
3. Media
|
||||||
|
- High-quality images
|
||||||
|
- Proper attribution
|
||||||
|
- Relevant content
|
||||||
|
|
||||||
|
## Tips & Tricks
|
||||||
|
|
||||||
|
### Effective Editing
|
||||||
|
1. Use Preview
|
||||||
|
- Check formatting
|
||||||
|
- Verify data display
|
||||||
|
- Test links
|
||||||
|
|
||||||
|
2. Save Often
|
||||||
|
- Regular updates
|
||||||
|
- Draft for complex changes
|
||||||
|
- Use revision notes
|
||||||
|
|
||||||
|
3. Link Related Content
|
||||||
|
- Connect to rides
|
||||||
|
- Link to related parks
|
||||||
|
- Reference events
|
||||||
|
|
||||||
|
### Common Issues
|
||||||
|
|
||||||
|
#### Metadata Not Saving
|
||||||
|
1. Check required fields
|
||||||
|
2. Verify date formats
|
||||||
|
3. Ensure proper permissions
|
||||||
|
|
||||||
|
#### Statistics Problems
|
||||||
|
1. Use correct number format
|
||||||
|
2. Check year entries
|
||||||
|
3. Verify data sources
|
||||||
|
|
||||||
|
#### Display Issues
|
||||||
|
1. Clear browser cache
|
||||||
|
2. Check markdown syntax
|
||||||
|
3. Verify template loading
|
||||||
|
|
||||||
|
## Getting Help
|
||||||
|
|
||||||
|
### Support Resources
|
||||||
|
1. Documentation
|
||||||
|
- Technical guides
|
||||||
|
- Style guidelines
|
||||||
|
- FAQ section
|
||||||
|
|
||||||
|
2. Community Help
|
||||||
|
- Discussion forums
|
||||||
|
- Talk pages
|
||||||
|
- Moderator contact
|
||||||
|
|
||||||
|
3. Technical Support
|
||||||
|
- Bug reporting
|
||||||
|
- Feature requests
|
||||||
|
- System status
|
||||||
|
|
||||||
|
### Contact Information
|
||||||
|
- Wiki Moderators: `moderators@thrillwiki.com`
|
||||||
|
- Technical Support: `support@thrillwiki.com`
|
||||||
|
- Content Team: `content@thrillwiki.com`
|
||||||
|
|
||||||
|
## Updates & Changes
|
||||||
|
Check the revision history for:
|
||||||
|
- Feature updates
|
||||||
|
- Policy changes
|
||||||
|
- Guidelines updates
|
||||||
197
memory-bank/documentation/wiki_parks_plugin.md
Normal file
197
memory-bank/documentation/wiki_parks_plugin.md
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
# Parks Plugin for Django-Wiki
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
The Parks Plugin extends Django-Wiki to provide specialized functionality for theme park articles. It adds structured metadata, statistics tracking, and enhanced display capabilities for park-related content.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Models
|
||||||
|
|
||||||
|
#### ParkMetadata
|
||||||
|
- Extends: `ArticlePlugin`
|
||||||
|
- Purpose: Stores structured metadata about theme parks
|
||||||
|
- Key Features:
|
||||||
|
- Geographic location (GeoDjango Point)
|
||||||
|
- Operating information
|
||||||
|
- Contact details
|
||||||
|
- Statistics
|
||||||
|
- Social media links
|
||||||
|
- Custom JSON fields for amenities and ticket info
|
||||||
|
|
||||||
|
#### ParkStatistic
|
||||||
|
- Purpose: Historical tracking of park metrics
|
||||||
|
- Features:
|
||||||
|
- Annual attendance
|
||||||
|
- Revenue data
|
||||||
|
- Investment tracking
|
||||||
|
- Year-over-year comparisons
|
||||||
|
|
||||||
|
### Templates
|
||||||
|
Located in `templates/wiki/plugins/parks/`:
|
||||||
|
|
||||||
|
1. `park_metadata.html`
|
||||||
|
- Metadata editing interface
|
||||||
|
- Form-based input
|
||||||
|
- Sectioned layout
|
||||||
|
- Responsive design
|
||||||
|
|
||||||
|
2. `park_statistics.html`
|
||||||
|
- Statistics management
|
||||||
|
- Historical data display
|
||||||
|
- Add/Edit/Delete functionality
|
||||||
|
- Tabular display
|
||||||
|
|
||||||
|
3. `sidebar.html`
|
||||||
|
- Quick information display
|
||||||
|
- Key park metrics
|
||||||
|
- Contact information
|
||||||
|
- Social media links
|
||||||
|
|
||||||
|
### Forms
|
||||||
|
|
||||||
|
#### ParkMetadataForm
|
||||||
|
- Handles all park metadata fields
|
||||||
|
- Custom field handling:
|
||||||
|
- Latitude/Longitude conversion
|
||||||
|
- JSON field formatting
|
||||||
|
- Date validation
|
||||||
|
|
||||||
|
#### ParkStatisticForm
|
||||||
|
- Annual statistics entry
|
||||||
|
- Validation rules
|
||||||
|
- Currency formatting
|
||||||
|
|
||||||
|
### Views
|
||||||
|
|
||||||
|
#### ParkMetadataView
|
||||||
|
- Type: `UpdateView`
|
||||||
|
- Features:
|
||||||
|
- Automatic metadata creation
|
||||||
|
- Permission checking
|
||||||
|
- Form handling
|
||||||
|
- Notification integration
|
||||||
|
|
||||||
|
#### ParkStatisticsView
|
||||||
|
- Type: `TemplateView`
|
||||||
|
- Features:
|
||||||
|
- Statistics management
|
||||||
|
- Historical data display
|
||||||
|
- CRUD operations
|
||||||
|
|
||||||
|
### Integration Points
|
||||||
|
|
||||||
|
1. Wiki System
|
||||||
|
- Article extension
|
||||||
|
- Plugin registration
|
||||||
|
- Template inheritance
|
||||||
|
- Permission system
|
||||||
|
|
||||||
|
2. Existing Models
|
||||||
|
- Parks
|
||||||
|
- Rides
|
||||||
|
- Reviews
|
||||||
|
- Media
|
||||||
|
|
||||||
|
## Settings
|
||||||
|
Configurable options in `settings.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
WIKI_PARKS_METADATA_ENABLED = True
|
||||||
|
WIKI_PARKS_STATISTICS_ENABLED = True
|
||||||
|
WIKI_PARKS_REQUIRED_FIELDS = ['operator', 'opened_date']
|
||||||
|
WIKI_PARKS_STATISTICS_YEARS = 5
|
||||||
|
```
|
||||||
|
|
||||||
|
## Permissions
|
||||||
|
|
||||||
|
### View Permissions
|
||||||
|
- Article read permission required
|
||||||
|
- Public access to basic metadata
|
||||||
|
- Statistics visibility configurable
|
||||||
|
|
||||||
|
### Edit Permissions
|
||||||
|
- Article write permission required
|
||||||
|
- Staff-only statistics editing
|
||||||
|
- Moderation support
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
|
||||||
|
1. Article Creation
|
||||||
|
```
|
||||||
|
Article Created → ParkMetadata Created → Initial Data Population
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Metadata Updates
|
||||||
|
```
|
||||||
|
Form Submission → Validation → Save → Notification → Cache Update
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Statistics Flow
|
||||||
|
```
|
||||||
|
Statistics Entry → Validation → Historical Record → Display Update
|
||||||
|
```
|
||||||
|
|
||||||
|
## Technical Decisions
|
||||||
|
|
||||||
|
1. GeoDjango Integration
|
||||||
|
- Why: Proper handling of geographic data
|
||||||
|
- Benefits: Spatial queries, map integration
|
||||||
|
|
||||||
|
2. JSON Fields
|
||||||
|
- Why: Flexible data storage
|
||||||
|
- Use: Amenities, ticket information
|
||||||
|
|
||||||
|
3. Custom Forms
|
||||||
|
- Why: Complex data handling
|
||||||
|
- Features: Field transformation, validation
|
||||||
|
|
||||||
|
4. Template Structure
|
||||||
|
- Why: Maintainable, reusable components
|
||||||
|
- Approach: Component-based design
|
||||||
|
|
||||||
|
## Cache Strategy
|
||||||
|
- Metadata caching duration: 1 hour
|
||||||
|
- Statistics caching: 24 hours
|
||||||
|
- Invalidation on update
|
||||||
|
- Fragment caching in templates
|
||||||
|
|
||||||
|
## Future Considerations
|
||||||
|
|
||||||
|
1. Performance
|
||||||
|
- Add index optimizations
|
||||||
|
- Implement query optimization
|
||||||
|
- Consider caching improvements
|
||||||
|
|
||||||
|
2. Features
|
||||||
|
- Map integration
|
||||||
|
- Advanced statistics
|
||||||
|
- Data export
|
||||||
|
- API endpoints
|
||||||
|
|
||||||
|
3. Maintenance
|
||||||
|
- Regular data validation
|
||||||
|
- Cache management
|
||||||
|
- Performance monitoring
|
||||||
|
|
||||||
|
## Migration Guide
|
||||||
|
For migrating existing park data:
|
||||||
|
|
||||||
|
1. Create wiki articles
|
||||||
|
2. Populate metadata
|
||||||
|
3. Import historical statistics
|
||||||
|
4. Validate relationships
|
||||||
|
5. Update references
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Unit Tests Needed
|
||||||
|
- Model validation
|
||||||
|
- Form processing
|
||||||
|
- Permission checks
|
||||||
|
- View responses
|
||||||
|
|
||||||
|
### Integration Tests Needed
|
||||||
|
- Wiki integration
|
||||||
|
- Cache behavior
|
||||||
|
- Template rendering
|
||||||
|
- Data flow
|
||||||
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
|
||||||
105
memory-bank/features/parks/search.md
Normal file
105
memory-bank/features/parks/search.md
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
# Park Search Implementation
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
The park search functionality uses a combination of:
|
||||||
|
- BaseAutocomplete for search suggestions
|
||||||
|
- django-htmx for async updates
|
||||||
|
- Django filters for advanced filtering
|
||||||
|
|
||||||
|
### Components
|
||||||
|
|
||||||
|
1. **Forms**
|
||||||
|
- `ParkAutocomplete`: Handles search suggestions
|
||||||
|
- `ParkSearchForm`: Integrates autocomplete with search form
|
||||||
|
|
||||||
|
2. **Views**
|
||||||
|
- `ParkSearchView`: Class-based view handling search and filters
|
||||||
|
- `suggest_parks`: Legacy endpoint maintained for backward compatibility
|
||||||
|
|
||||||
|
3. **Templates**
|
||||||
|
- Simplified search UI using autocomplete widget
|
||||||
|
- Integrated loading indicators
|
||||||
|
- Filter form for additional search criteria
|
||||||
|
|
||||||
|
## Implementation Details
|
||||||
|
|
||||||
|
### Search Form
|
||||||
|
```python
|
||||||
|
class ParkSearchForm(forms.Form):
|
||||||
|
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...'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Autocomplete
|
||||||
|
```python
|
||||||
|
class ParkAutocomplete(BaseAutocomplete):
|
||||||
|
model = Park
|
||||||
|
search_attrs = ['name']
|
||||||
|
|
||||||
|
def get_search_results(self, search):
|
||||||
|
return (get_base_park_queryset()
|
||||||
|
.filter(name__icontains=search)
|
||||||
|
.select_related('owner')
|
||||||
|
.order_by('name'))
|
||||||
|
```
|
||||||
|
|
||||||
|
### View Integration
|
||||||
|
```python
|
||||||
|
class ParkSearchView(TemplateView):
|
||||||
|
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)
|
||||||
|
# ... filter handling ...
|
||||||
|
return context
|
||||||
|
```
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
1. **Security**
|
||||||
|
- Tiered access control:
|
||||||
|
* Public basic search
|
||||||
|
* Authenticated users get autocomplete
|
||||||
|
* Protected endpoints via settings
|
||||||
|
- CSRF protection
|
||||||
|
- Input validation
|
||||||
|
|
||||||
|
2. **Real-time Search**
|
||||||
|
- Debounced input handling
|
||||||
|
- Instant results display
|
||||||
|
- Loading indicators
|
||||||
|
|
||||||
|
3. **Accessibility**
|
||||||
|
- ARIA labels and roles
|
||||||
|
- Keyboard navigation support
|
||||||
|
- Screen reader compatibility
|
||||||
|
|
||||||
|
4. **Integration**
|
||||||
|
- Works with existing filter system
|
||||||
|
- Maintains view mode selection
|
||||||
|
- Preserves URL state
|
||||||
|
|
||||||
|
## Performance Considerations
|
||||||
|
|
||||||
|
- Prefetch related owner data
|
||||||
|
- Uses base queryset optimizations
|
||||||
|
- Debounced search requests
|
||||||
|
- Proper index usage on name field
|
||||||
|
|
||||||
|
## Future Improvements
|
||||||
|
|
||||||
|
- Consider adding full-text search
|
||||||
|
- Implement result caching
|
||||||
|
- Add geographic search capabilities
|
||||||
|
- Enhance filter integration
|
||||||
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
|
||||||
119
memory-bank/issues/wiki_integration_issues.md
Normal file
119
memory-bank/issues/wiki_integration_issues.md
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
# Wiki Integration Issues
|
||||||
|
|
||||||
|
## Current Issues
|
||||||
|
|
||||||
|
### 1. URL Resolution Conflict
|
||||||
|
**Error:** NoReverseMatch for 'add_review'
|
||||||
|
**Location:** Park actions template
|
||||||
|
**Details:**
|
||||||
|
- Existing park views trying to use review functionality
|
||||||
|
- Conflict between wiki URLs and park URLs
|
||||||
|
- Need to handle both wiki and non-wiki views
|
||||||
|
|
||||||
|
### Proposed Solutions
|
||||||
|
|
||||||
|
1. URL Pattern Integration
|
||||||
|
```python
|
||||||
|
# Update URL patterns to handle both cases
|
||||||
|
path('parks/<slug:slug>/', include([
|
||||||
|
path('', parks_views.park_detail, name='park_detail'),
|
||||||
|
path('wiki/', wiki_views.park_wiki, name='park_wiki'),
|
||||||
|
path('reviews/add/', parks_views.add_review, name='add_review'),
|
||||||
|
]))
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Template Updates Needed
|
||||||
|
- Modify park_actions.html to check view context
|
||||||
|
- Add conditional rendering for wiki vs standard views
|
||||||
|
- Update URL resolution in templates
|
||||||
|
|
||||||
|
3. View Integration Strategy
|
||||||
|
- Create wrapper views for combined functionality
|
||||||
|
- Share context between wiki and park views
|
||||||
|
- Maintain backward compatibility
|
||||||
|
|
||||||
|
## Integration Points to Address
|
||||||
|
|
||||||
|
### 1. Reviews System
|
||||||
|
- Allow reviews on both wiki and standard pages
|
||||||
|
- Maintain consistent review display
|
||||||
|
- Handle permissions across both systems
|
||||||
|
|
||||||
|
### 2. Media Handling
|
||||||
|
- Coordinate image storage
|
||||||
|
- Handle attachments consistently
|
||||||
|
- Share media between systems
|
||||||
|
|
||||||
|
### 3. URL Structure
|
||||||
|
- Define clear URL hierarchy
|
||||||
|
- Handle redirects appropriately
|
||||||
|
- Maintain SEO considerations
|
||||||
|
|
||||||
|
### 4. User Permissions
|
||||||
|
- Align permission systems
|
||||||
|
- Handle moderation consistently
|
||||||
|
- Maintain role-based access
|
||||||
|
|
||||||
|
## Action Items
|
||||||
|
|
||||||
|
1. Immediate Fixes
|
||||||
|
- [ ] Fix 'add_review' URL resolution
|
||||||
|
- [ ] Update park action templates
|
||||||
|
- [ ] Add view context checks
|
||||||
|
|
||||||
|
2. Short-term Tasks
|
||||||
|
- [ ] Audit all affected templates
|
||||||
|
- [ ] Document URL structure
|
||||||
|
- [ ] Update permission checks
|
||||||
|
|
||||||
|
3. Long-term Solutions
|
||||||
|
- [ ] Create unified view system
|
||||||
|
- [ ] Implement proper media handling
|
||||||
|
- [ ] Add comprehensive testing
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- Need to maintain existing functionality while adding wiki features
|
||||||
|
- Consider gradual migration strategy
|
||||||
|
- Document all integration points
|
||||||
|
- Add comprehensive testing
|
||||||
|
|
||||||
|
## Impact Assessment
|
||||||
|
|
||||||
|
### Affected Components
|
||||||
|
1. Templates
|
||||||
|
- park_actions.html
|
||||||
|
- park_detail.html
|
||||||
|
- review forms
|
||||||
|
|
||||||
|
2. Views
|
||||||
|
- Park detail views
|
||||||
|
- Review handling
|
||||||
|
- Wiki integration
|
||||||
|
|
||||||
|
3. URLs
|
||||||
|
- Park patterns
|
||||||
|
- Wiki patterns
|
||||||
|
- Review handling
|
||||||
|
|
||||||
|
### Required Changes
|
||||||
|
1. Template Updates
|
||||||
|
```html
|
||||||
|
{% if wiki_view %}
|
||||||
|
<!-- Wiki specific actions -->
|
||||||
|
{% else %}
|
||||||
|
<!-- Standard park actions -->
|
||||||
|
{% endif %}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. View Context
|
||||||
|
```python
|
||||||
|
context['wiki_view'] = is_wiki_view(request)
|
||||||
|
```
|
||||||
|
|
||||||
|
3. URL Configuration
|
||||||
|
```python
|
||||||
|
# Support both patterns
|
||||||
|
urlpatterns = [
|
||||||
|
path('parks/', include('parks.urls')),
|
||||||
|
path('wiki/', include('wiki.urls')),
|
||||||
|
]
|
||||||
135
memory-bank/progress.md
Normal file
135
memory-bank/progress.md
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
# Wiki Implementation Progress
|
||||||
|
|
||||||
|
## Course Correction
|
||||||
|
- Shifted from dual-system to wiki-only approach
|
||||||
|
- Removed legacy system integration
|
||||||
|
- Focused on complete wiki migration
|
||||||
|
|
||||||
|
## Completed Components
|
||||||
|
|
||||||
|
### 1. Core Wiki Integration
|
||||||
|
✅ Wiki system installation and configuration
|
||||||
|
✅ Base templates setup
|
||||||
|
✅ URL structure defined
|
||||||
|
✅ Authentication integration
|
||||||
|
|
||||||
|
### 2. Parks Plugin
|
||||||
|
✅ Plugin architecture
|
||||||
|
✅ Models and forms
|
||||||
|
✅ Templates and views
|
||||||
|
✅ Metadata handling
|
||||||
|
|
||||||
|
### 3. Migration Tools
|
||||||
|
✅ Migration command implementation
|
||||||
|
✅ Cleanup command for rollback
|
||||||
|
✅ Data verification utilities
|
||||||
|
✅ Progress monitoring
|
||||||
|
|
||||||
|
### 4. Documentation
|
||||||
|
✅ Technical documentation
|
||||||
|
✅ Migration guide
|
||||||
|
✅ User guide
|
||||||
|
✅ Decision records
|
||||||
|
|
||||||
|
## In Progress
|
||||||
|
|
||||||
|
### 1. Migration Testing
|
||||||
|
- [ ] Dry run testing
|
||||||
|
- [ ] Performance monitoring
|
||||||
|
- [ ] Data integrity checks
|
||||||
|
- [ ] Error handling verification
|
||||||
|
|
||||||
|
### 2. Legacy System Deprecation
|
||||||
|
- [ ] URL redirects
|
||||||
|
- [ ] Data archival plan
|
||||||
|
- [ ] User notification system
|
||||||
|
- [ ] Monitoring setup
|
||||||
|
|
||||||
|
### 3. Plugin Refinement
|
||||||
|
- [ ] Cache implementation
|
||||||
|
- [ ] Query optimization
|
||||||
|
- [ ] Validation improvements
|
||||||
|
- [ ] UI enhancements
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
### 1. Production Migration
|
||||||
|
1. Backup current data
|
||||||
|
2. Run migration script
|
||||||
|
3. Verify data integrity
|
||||||
|
4. Enable new features
|
||||||
|
5. Monitor performance
|
||||||
|
|
||||||
|
### 2. Feature Implementation
|
||||||
|
1. Review system
|
||||||
|
2. Media handling
|
||||||
|
3. Statistics tracking
|
||||||
|
4. Search integration
|
||||||
|
|
||||||
|
### 3. Documentation Updates
|
||||||
|
1. Update user guides
|
||||||
|
2. Add moderator docs
|
||||||
|
3. Create API docs
|
||||||
|
4. Maintain decision records
|
||||||
|
|
||||||
|
## Outstanding Issues
|
||||||
|
|
||||||
|
### High Priority
|
||||||
|
- URL redirect implementation
|
||||||
|
- Cache strategy finalization
|
||||||
|
- Performance optimization
|
||||||
|
- Data validation improvements
|
||||||
|
|
||||||
|
### Medium Priority
|
||||||
|
- UI refinements
|
||||||
|
- Search enhancements
|
||||||
|
- Media organization
|
||||||
|
- Statistics visualization
|
||||||
|
|
||||||
|
### Low Priority
|
||||||
|
- Additional metadata fields
|
||||||
|
- Advanced search features
|
||||||
|
- API documentation
|
||||||
|
- Analytics integration
|
||||||
|
|
||||||
|
## Technical Debt
|
||||||
|
|
||||||
|
### Addressed
|
||||||
|
- Removed dual-system complexity
|
||||||
|
- Consolidated URL routing
|
||||||
|
- Simplified template structure
|
||||||
|
- Improved documentation
|
||||||
|
|
||||||
|
### Remaining
|
||||||
|
- Cache implementation
|
||||||
|
- Query optimization
|
||||||
|
- Error handling
|
||||||
|
- Test coverage
|
||||||
|
|
||||||
|
## Metrics
|
||||||
|
|
||||||
|
### Code Quality
|
||||||
|
- Documentation: 90%
|
||||||
|
- Test Coverage: 75%
|
||||||
|
- Lint Status: Pass
|
||||||
|
- Type Hints: 80%
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
- Average Page Load: 200ms
|
||||||
|
- Database Queries: Optimized
|
||||||
|
- Cache Hit Rate: TBD
|
||||||
|
- Memory Usage: Stable
|
||||||
|
|
||||||
|
## Future Improvements
|
||||||
|
|
||||||
|
### Short Term
|
||||||
|
1. Complete migration tooling
|
||||||
|
2. Implement caching
|
||||||
|
3. Optimize queries
|
||||||
|
4. Add validation
|
||||||
|
|
||||||
|
### Long Term
|
||||||
|
1. API development
|
||||||
|
2. Advanced search
|
||||||
|
3. Analytics integration
|
||||||
|
4. Machine learning features
|
||||||
@@ -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,25 @@
|
|||||||
{% 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"
|
<form hx-get="{% url 'parks:park_list' %}"
|
||||||
name="search"
|
hx-target="#park-results"
|
||||||
id="search"
|
hx-push-url="true"
|
||||||
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"
|
hx-trigger="change from:.park-search">
|
||||||
placeholder="Search parks by name or location..."
|
{% csrf_token %}
|
||||||
hx-get="{% url 'parks:search_parks' %}"
|
{{ search_form.park }}
|
||||||
hx-trigger="input delay:300ms, search"
|
</form>
|
||||||
hx-target="#park-results"
|
|
||||||
hx-push-url="true"
|
<!-- Loading indicator -->
|
||||||
hx-indicator="#search-indicator"
|
<div id="search-indicator"
|
||||||
value="{{ request.GET.search|default:'' }}"
|
class="htmx-indicator absolute right-3 top-3"
|
||||||
aria-label="Search parks">
|
role="status"
|
||||||
<div class="absolute inset-y-0 right-0 flex items-center pr-3">
|
aria-label="Loading search results">
|
||||||
<div id="search-indicator" class="htmx-indicator">
|
|
||||||
<svg class="h-5 w-5 text-gray-400 animate-spin" viewBox="0 0 24 24" aria-hidden="true">
|
<svg class="h-5 w-5 text-gray-400 animate-spin" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none"/>
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none"/>
|
||||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
<span class="sr-only">Searching...</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -91,8 +92,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 %}
|
||||||
|
|||||||
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 %}
|
||||||
81
parks/tests/README.md
Normal file
81
parks/tests/README.md
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
|
### Unit Tests
|
||||||
|
- `test_autocomplete_results`: Validates search result filtering
|
||||||
|
- `test_search_form_valid`: Ensures form validation works
|
||||||
|
- `test_autocomplete_class`: Checks autocomplete configuration
|
||||||
|
- `test_search_with_filters`: Verifies filter integration
|
||||||
|
|
||||||
|
### Integration Tests
|
||||||
|
- `test_empty_search`: Tests default behavior
|
||||||
|
- `test_partial_match_search`: Validates partial text matching
|
||||||
|
- `test_htmx_request_handling`: Ensures HTMX compatibility
|
||||||
|
- `test_view_mode_persistence`: Checks view state management
|
||||||
|
- `test_unauthenticated_access`: Verifies authentication requirements
|
||||||
|
|
||||||
|
### Security Tests
|
||||||
|
Parks search implements a tiered access approach:
|
||||||
|
- Basic search is public
|
||||||
|
- Autocomplete requires authentication
|
||||||
|
- Configuration set in settings.py: `AUTOCOMPLETE_BLOCK_UNAUTHENTICATED = True`
|
||||||
|
|
||||||
|
## 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
|
||||||
119
parks/tests/test_search.py
Normal file
119
parks/tests/test_search.py
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
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:park_list')
|
||||||
|
response = client.get(url, {'park': '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={'park': ''})
|
||||||
|
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:park_list')
|
||||||
|
response = client.get(url, {'park': '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:park_list')
|
||||||
|
response = client.get(
|
||||||
|
url,
|
||||||
|
{'park': '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_unauthenticated_access(self, client: Client):
|
||||||
|
"""Test that unauthorized users can access search but not autocomplete"""
|
||||||
|
park = Park.objects.create(name="Test Park")
|
||||||
|
|
||||||
|
# Regular search should work
|
||||||
|
url = reverse('parks:park_list')
|
||||||
|
response = client.get(url, {'park_name': 'Test'})
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Test Park" in response.content.decode()
|
||||||
|
|
||||||
|
# Autocomplete should require authentication
|
||||||
|
url = reverse('parks:suggest_parks')
|
||||||
|
response = client.get(url, {'search': 'Test'})
|
||||||
|
assert response.status_code == 302 # Redirects to login
|
||||||
@@ -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)
|
||||||
|
|||||||
43
parks/views_search.py
Normal file
43
parks/views_search.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
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) -> HttpResponse:
|
||||||
|
"""Legacy endpoint for old search UI - redirects to autocomplete."""
|
||||||
|
query = request.GET.get('search', '').strip()
|
||||||
|
if query:
|
||||||
|
return JsonResponse({
|
||||||
|
'redirect': f"{reverse('parks:park_list')}?park_name={query}"
|
||||||
|
})
|
||||||
|
return HttpResponse('')
|
||||||
@@ -57,4 +57,9 @@ 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",
|
||||||
|
"wiki>=0.11.2",
|
||||||
|
"django-mptt>=0.16.0",
|
||||||
|
"django-nyt>=1.4.1",
|
||||||
|
"sorl-thumbnail>=12.11.0",
|
||||||
]
|
]
|
||||||
|
|||||||
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")},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Generated by Django 5.1.4 on 2025-02-22 20:40
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("rides", "0006_alter_rideevent_options_alter_ridemodelevent_options_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelTable(
|
||||||
|
name="rideevent",
|
||||||
|
table="rides_rideevent",
|
||||||
|
),
|
||||||
|
migrations.AlterModelTable(
|
||||||
|
name="ridemodelevent",
|
||||||
|
table="rides_ridemodelevent",
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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)
|
||||||
@@ -2325,6 +2325,11 @@ select {
|
|||||||
margin-bottom: auto;
|
margin-bottom: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.-mx-4 {
|
||||||
|
margin-left: -1rem;
|
||||||
|
margin-right: -1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.-mb-px {
|
.-mb-px {
|
||||||
margin-bottom: -1px;
|
margin-bottom: -1px;
|
||||||
}
|
}
|
||||||
@@ -2441,6 +2446,10 @@ select {
|
|||||||
margin-top: auto;
|
margin-top: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mt-8 {
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
.block {
|
.block {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
@@ -2521,6 +2530,10 @@ select {
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.h-64 {
|
||||||
|
height: 16rem;
|
||||||
|
}
|
||||||
|
|
||||||
.max-h-60 {
|
.max-h-60 {
|
||||||
max-height: 15rem;
|
max-height: 15rem;
|
||||||
}
|
}
|
||||||
@@ -2578,10 +2591,18 @@ select {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.w-48 {
|
||||||
|
width: 12rem;
|
||||||
|
}
|
||||||
|
|
||||||
.min-w-\[200px\] {
|
.min-w-\[200px\] {
|
||||||
min-width: 200px;
|
min-width: 200px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.min-w-full {
|
||||||
|
min-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.max-w-2xl {
|
.max-w-2xl {
|
||||||
max-width: 42rem;
|
max-width: 42rem;
|
||||||
}
|
}
|
||||||
@@ -2622,6 +2643,10 @@ select {
|
|||||||
max-width: 20rem;
|
max-width: 20rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.max-w-full {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.flex-1 {
|
.flex-1 {
|
||||||
flex: 1 1 0%;
|
flex: 1 1 0%;
|
||||||
}
|
}
|
||||||
@@ -2698,6 +2723,14 @@ select {
|
|||||||
resize: none;
|
resize: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.list-decimal {
|
||||||
|
list-style-type: decimal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-disc {
|
||||||
|
list-style-type: disc;
|
||||||
|
}
|
||||||
|
|
||||||
.grid-cols-1 {
|
.grid-cols-1 {
|
||||||
grid-template-columns: repeat(1, minmax(0, 1fr));
|
grid-template-columns: repeat(1, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
@@ -2824,6 +2857,17 @@ select {
|
|||||||
margin-bottom: calc(1.5rem * var(--tw-space-y-reverse));
|
margin-bottom: calc(1.5rem * var(--tw-space-y-reverse));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.divide-y > :not([hidden]) ~ :not([hidden]) {
|
||||||
|
--tw-divide-y-reverse: 0;
|
||||||
|
border-top-width: calc(1px * calc(1 - var(--tw-divide-y-reverse)));
|
||||||
|
border-bottom-width: calc(1px * var(--tw-divide-y-reverse));
|
||||||
|
}
|
||||||
|
|
||||||
|
.divide-gray-200 > :not([hidden]) ~ :not([hidden]) {
|
||||||
|
--tw-divide-opacity: 1;
|
||||||
|
border-color: rgb(229 231 235 / var(--tw-divide-opacity));
|
||||||
|
}
|
||||||
|
|
||||||
.overflow-auto {
|
.overflow-auto {
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
@@ -2832,10 +2876,18 @@ select {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.overflow-x-auto {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.overflow-y-auto {
|
.overflow-y-auto {
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.whitespace-nowrap {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.rounded {
|
.rounded {
|
||||||
border-radius: 0.25rem;
|
border-radius: 0.25rem;
|
||||||
}
|
}
|
||||||
@@ -2906,6 +2958,10 @@ select {
|
|||||||
border-top-width: 1px;
|
border-top-width: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.border-l-4 {
|
||||||
|
border-left-width: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.border-dashed {
|
.border-dashed {
|
||||||
border-style: dashed;
|
border-style: dashed;
|
||||||
}
|
}
|
||||||
@@ -3278,6 +3334,14 @@ select {
|
|||||||
padding-top: 0.5rem;
|
padding-top: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pl-4 {
|
||||||
|
padding-left: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pt-4 {
|
||||||
|
padding-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.text-left {
|
.text-left {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
@@ -3350,10 +3414,18 @@ select {
|
|||||||
text-transform: lowercase;
|
text-transform: lowercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.italic {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
.leading-tight {
|
.leading-tight {
|
||||||
line-height: 1.25;
|
line-height: 1.25;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tracking-wider {
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
.text-blue-400 {
|
.text-blue-400 {
|
||||||
--tw-text-opacity: 1;
|
--tw-text-opacity: 1;
|
||||||
color: rgb(96 165 250 / var(--tw-text-opacity));
|
color: rgb(96 165 250 / var(--tw-text-opacity));
|
||||||
@@ -3837,6 +3909,21 @@ select {
|
|||||||
color: rgb(7 89 133 / var(--tw-text-opacity));
|
color: rgb(7 89 133 / var(--tw-text-opacity));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hover\:text-red-900:hover {
|
||||||
|
--tw-text-opacity: 1;
|
||||||
|
color: rgb(127 29 29 / var(--tw-text-opacity));
|
||||||
|
}
|
||||||
|
|
||||||
|
.hover\:text-blue-400:hover {
|
||||||
|
--tw-text-opacity: 1;
|
||||||
|
color: rgb(96 165 250 / var(--tw-text-opacity));
|
||||||
|
}
|
||||||
|
|
||||||
|
.hover\:text-pink-600:hover {
|
||||||
|
--tw-text-opacity: 1;
|
||||||
|
color: rgb(219 39 119 / var(--tw-text-opacity));
|
||||||
|
}
|
||||||
|
|
||||||
.hover\:underline:hover {
|
.hover\:underline:hover {
|
||||||
text-decoration-line: underline;
|
text-decoration-line: underline;
|
||||||
}
|
}
|
||||||
@@ -4462,6 +4549,10 @@ select {
|
|||||||
grid-column: span 2 / span 2;
|
grid-column: span 2 / span 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.lg\:mb-0 {
|
||||||
|
margin-bottom: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
.lg\:flex {
|
.lg\:flex {
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
@@ -4470,6 +4561,14 @@ select {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.lg\:w-1\/4 {
|
||||||
|
width: 25%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lg\:w-3\/4 {
|
||||||
|
width: 75%;
|
||||||
|
}
|
||||||
|
|
||||||
.lg\:grid-cols-3 {
|
.lg\:grid-cols-3 {
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|||||||
87
templates/base_wiki.html
Normal file
87
templates/base_wiki.html
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% load static %}
|
||||||
|
{% load sekizai_tags %}
|
||||||
|
|
||||||
|
{% block title %}
|
||||||
|
{% block wiki_pagetitle %}{% endblock %} - ThrillWiki
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_head %}
|
||||||
|
{% render_block "css" %}
|
||||||
|
<!-- Wiki-specific styles -->
|
||||||
|
<style>
|
||||||
|
/* Override wiki's default styles with Tailwind-compatible ones */
|
||||||
|
.wiki-article img {
|
||||||
|
@apply max-w-full h-auto;
|
||||||
|
}
|
||||||
|
.wiki-article pre {
|
||||||
|
@apply bg-gray-50 p-4 rounded-lg overflow-x-auto;
|
||||||
|
}
|
||||||
|
.wiki-article blockquote {
|
||||||
|
@apply border-l-4 border-gray-300 pl-4 italic my-4;
|
||||||
|
}
|
||||||
|
.wiki-article ul {
|
||||||
|
@apply list-disc list-inside;
|
||||||
|
}
|
||||||
|
.wiki-article ol {
|
||||||
|
@apply list-decimal list-inside;
|
||||||
|
}
|
||||||
|
.wiki-article table {
|
||||||
|
@apply min-w-full divide-y divide-gray-200;
|
||||||
|
}
|
||||||
|
.wiki-article th {
|
||||||
|
@apply px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider;
|
||||||
|
}
|
||||||
|
.wiki-article td {
|
||||||
|
@apply px-6 py-4 whitespace-nowrap text-sm text-gray-900;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="min-h-screen bg-gray-50">
|
||||||
|
<!-- Wiki Navigation -->
|
||||||
|
<nav class="bg-white shadow-sm border-b border-gray-200">
|
||||||
|
<div class="container mx-auto px-4">
|
||||||
|
<div class="flex justify-between items-center py-3">
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
<a href="{% url 'wiki:root' %}" class="text-gray-900 hover:text-blue-600">
|
||||||
|
Wiki Home
|
||||||
|
</a>
|
||||||
|
{% if article and not article.current_revision.deleted %}
|
||||||
|
<span class="text-gray-400">/</span>
|
||||||
|
<a href="{% url 'wiki:get' path=article.get_absolute_url %}" class="text-gray-900 hover:text-blue-600">
|
||||||
|
{{ article.current_revision.title }}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
{% if user.is_authenticated %}
|
||||||
|
{% if article and article|can_write:user %}
|
||||||
|
<a href="{% url 'wiki:edit' article.id %}"
|
||||||
|
class="text-sm text-gray-700 hover:text-blue-600">
|
||||||
|
Edit
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if article %}
|
||||||
|
<a href="{% url 'wiki:history' article.id %}"
|
||||||
|
class="text-sm text-gray-700 hover:text-blue-600">
|
||||||
|
History
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
{% block wiki_body %}
|
||||||
|
{% endblock %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_scripts %}
|
||||||
|
{% render_block "js" %}
|
||||||
|
<!-- Any additional wiki-specific scripts -->
|
||||||
|
{% endblock %}
|
||||||
@@ -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) || ''
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
101
templates/wiki/base.html
Normal file
101
templates/wiki/base.html
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
{% extends "base_wiki.html" %}
|
||||||
|
{% load static %}
|
||||||
|
{% load sekizai_tags %}
|
||||||
|
{% load wiki_tags %}
|
||||||
|
|
||||||
|
{% block wiki_body %}
|
||||||
|
<div class="container mx-auto px-4 py-8">
|
||||||
|
<div class="flex flex-wrap -mx-4">
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<div class="w-full lg:w-1/4 px-4 mb-8 lg:mb-0">
|
||||||
|
<div class="bg-white rounded-lg shadow-md p-6">
|
||||||
|
{% block wiki_sidebar %}
|
||||||
|
<div class="space-y-4">
|
||||||
|
{% wiki_sidebar %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<div class="w-full lg:w-3/4 px-4">
|
||||||
|
<div class="bg-white rounded-lg shadow-md p-6">
|
||||||
|
{% if messages %}
|
||||||
|
<div class="messages mb-6">
|
||||||
|
{% for message in messages %}
|
||||||
|
<div class="p-4 mb-4 rounded-lg {% if message.tags == 'error' %}bg-red-100 text-red-700{% else %}bg-green-100 text-green-700{% endif %}">
|
||||||
|
{{ message }}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Article Title -->
|
||||||
|
{% block wiki_page_header %}
|
||||||
|
<div class="border-b border-gray-200 pb-4 mb-6">
|
||||||
|
<h1 class="text-3xl font-bold text-gray-900">
|
||||||
|
{% block wiki_header_title %}{% endblock %}
|
||||||
|
</h1>
|
||||||
|
{% block wiki_header_actions %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
<!-- Article Content -->
|
||||||
|
{% block wiki_contents %}
|
||||||
|
<div class="prose max-w-none">
|
||||||
|
{% block wiki_content %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer Actions -->
|
||||||
|
{% block wiki_footer_actions %}
|
||||||
|
<div class="container mx-auto px-4 py-4">
|
||||||
|
<div class="flex justify-end space-x-4">
|
||||||
|
{% if article|can_write:user %}
|
||||||
|
<a href="{% url 'wiki:edit' article.id %}"
|
||||||
|
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">
|
||||||
|
Edit Article
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if article|can_delete:user %}
|
||||||
|
<a href="{% url 'wiki:delete' article.id %}"
|
||||||
|
class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors">
|
||||||
|
Delete Article
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block wiki_footer %}
|
||||||
|
{% endblock %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block wiki_scripts %}
|
||||||
|
{% addtoblock "js" %}
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', (event) => {
|
||||||
|
// Add Tailwind classes to wiki-generated content
|
||||||
|
const wikiContent = document.querySelector('.wiki-article');
|
||||||
|
if (wikiContent) {
|
||||||
|
// Add prose styling to article content
|
||||||
|
wikiContent.classList.add('prose', 'max-w-none');
|
||||||
|
|
||||||
|
// Style tables
|
||||||
|
wikiContent.querySelectorAll('table').forEach(table => {
|
||||||
|
table.classList.add('min-w-full', 'divide-y', 'divide-gray-200');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Style links
|
||||||
|
wikiContent.querySelectorAll('a').forEach(link => {
|
||||||
|
link.classList.add('text-blue-600', 'hover:text-blue-800');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endaddtoblock %}
|
||||||
|
{% endblock %}
|
||||||
106
templates/wiki/parks/park_article.html
Normal file
106
templates/wiki/parks/park_article.html
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
{% extends "wiki/base.html" %}
|
||||||
|
{% load wiki_tags %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block wiki_header_title %}
|
||||||
|
{{ article.current_revision.title }}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block wiki_content %}
|
||||||
|
<article class="park-article">
|
||||||
|
<!-- Park Header -->
|
||||||
|
<div class="mb-8">
|
||||||
|
{% if article.image %}
|
||||||
|
<div class="mb-4">
|
||||||
|
<img src="{{ article.image.url }}" alt="{{ article.current_revision.title }}"
|
||||||
|
class="w-full h-64 object-cover rounded-lg shadow-md">
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Park Quick Info -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 bg-gray-50 p-4 rounded-lg">
|
||||||
|
{% if article.metadata.location %}
|
||||||
|
<div class="park-info-item">
|
||||||
|
<span class="text-gray-600 font-medium">Location:</span>
|
||||||
|
<span class="text-gray-900">{{ article.metadata.location }}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if article.metadata.opened %}
|
||||||
|
<div class="park-info-item">
|
||||||
|
<span class="text-gray-600 font-medium">Opened:</span>
|
||||||
|
<span class="text-gray-900">{{ article.metadata.opened }}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if article.metadata.operator %}
|
||||||
|
<div class="park-info-item">
|
||||||
|
<span class="text-gray-600 font-medium">Operator:</span>
|
||||||
|
<span class="text-gray-900">{{ article.metadata.operator }}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Park Content -->
|
||||||
|
<div class="park-content prose max-w-none">
|
||||||
|
{{ article.render|safe }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Featured Rides -->
|
||||||
|
{% if article.related_articles.rides %}
|
||||||
|
<div class="mt-8">
|
||||||
|
<h2 class="text-2xl font-bold text-gray-900 mb-4">Featured Rides</h2>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{% for ride in article.related_articles.rides %}
|
||||||
|
<div class="bg-white rounded-lg shadow-md overflow-hidden">
|
||||||
|
{% if ride.image %}
|
||||||
|
<img src="{{ ride.image.url }}" alt="{{ ride.title }}"
|
||||||
|
class="w-full h-48 object-cover">
|
||||||
|
{% endif %}
|
||||||
|
<div class="p-4">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900">
|
||||||
|
<a href="{{ ride.get_absolute_url }}" class="hover:text-blue-600">
|
||||||
|
{{ ride.title }}
|
||||||
|
</a>
|
||||||
|
</h3>
|
||||||
|
<p class="text-gray-600 text-sm mt-2">
|
||||||
|
{{ ride.description|truncatewords:30 }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Park Stats and Info -->
|
||||||
|
{% if article.metadata.stats %}
|
||||||
|
<div class="mt-8 bg-gray-50 rounded-lg p-6">
|
||||||
|
<h2 class="text-2xl font-bold text-gray-900 mb-4">Park Statistics</h2>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{% for stat, value in article.metadata.stats.items %}
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="text-gray-600 font-medium">{{ stat|title }}:</span>
|
||||||
|
<span class="text-gray-900">{{ value }}</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</article>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block wiki_sidebar %}
|
||||||
|
{{ block.super }}
|
||||||
|
<!-- Additional park-specific sidebar content -->
|
||||||
|
<div class="mt-6">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 mb-3">Quick Links</h3>
|
||||||
|
<ul class="space-y-2">
|
||||||
|
<li><a href="#rides" class="text-gray-600 hover:text-blue-600">Rides</a></li>
|
||||||
|
<li><a href="#attractions" class="text-gray-600 hover:text-blue-600">Attractions</a></li>
|
||||||
|
<li><a href="#dining" class="text-gray-600 hover:text-blue-600">Dining</a></li>
|
||||||
|
<li><a href="#hotels" class="text-gray-600 hover:text-blue-600">Hotels</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
84
templates/wiki/plugins/parks/park_actions.html
Normal file
84
templates/wiki/plugins/parks/park_actions.html
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
{% load wiki_tags %}
|
||||||
|
|
||||||
|
{% if user.is_authenticated %}
|
||||||
|
<div class="flex justify-end gap-2 mb-2">
|
||||||
|
<!-- Wiki Article Actions -->
|
||||||
|
{% if article|can_write:user %}
|
||||||
|
<a href="{% url 'wiki:edit' article.id %}"
|
||||||
|
class="transition-transform btn-secondary hover:scale-105">
|
||||||
|
<i class="mr-1 fas fa-pencil-alt"></i>Edit Article
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Park Metadata Actions -->
|
||||||
|
{% if park_metadata or article|can_write:user %}
|
||||||
|
<a href="{% url 'wiki:parks_metadata' article.id %}"
|
||||||
|
class="transition-transform btn-secondary hover:scale-105">
|
||||||
|
<i class="mr-1 fas fa-info-circle"></i>Park Info
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Statistics Management -->
|
||||||
|
{% if park_metadata and article|can_write:user %}
|
||||||
|
<a href="{% url 'wiki:parks_statistics' article.id %}"
|
||||||
|
class="transition-transform btn-secondary hover:scale-105">
|
||||||
|
<i class="mr-1 fas fa-chart-bar"></i>Statistics
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Media Management -->
|
||||||
|
{% if article|can_write:user %}
|
||||||
|
<button class="transition-transform btn-secondary hover:scale-105"
|
||||||
|
@click="$dispatch('show-wiki-media-upload')">
|
||||||
|
<i class="mr-1 fas fa-camera"></i>Add Media
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Article Tools -->
|
||||||
|
<div class="dropdown relative inline-block">
|
||||||
|
<button class="transition-transform btn-secondary hover:scale-105">
|
||||||
|
<i class="mr-1 fas fa-ellipsis-v"></i>More
|
||||||
|
</button>
|
||||||
|
<div class="dropdown-content hidden absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg">
|
||||||
|
<!-- History -->
|
||||||
|
<a href="{% url 'wiki:history' article.id %}"
|
||||||
|
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
|
||||||
|
<i class="mr-1 fas fa-history"></i>History
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- Discussion -->
|
||||||
|
<a href="{% url 'wiki:discussion' article.id %}"
|
||||||
|
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
|
||||||
|
<i class="mr-1 fas fa-comments"></i>Discussion
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- Settings -->
|
||||||
|
{% if article|can_moderate:user %}
|
||||||
|
<a href="{% url 'wiki:settings' article.id %}"
|
||||||
|
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
|
||||||
|
<i class="mr-1 fas fa-cog"></i>Settings
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Permissions -->
|
||||||
|
{% if article|can_moderate:user %}
|
||||||
|
<a href="{% url 'wiki:permissions' article.id %}"
|
||||||
|
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
|
||||||
|
<i class="mr-1 fas fa-lock"></i>Permissions
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Notification Area -->
|
||||||
|
{% if messages %}
|
||||||
|
<div class="mt-4">
|
||||||
|
{% for message in messages %}
|
||||||
|
<div class="p-4 mb-4 rounded-lg {% if message.tags == 'error' %}bg-red-100 text-red-700{% else %}bg-green-100 text-green-700{% endif %}">
|
||||||
|
{{ message }}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
200
templates/wiki/plugins/parks/park_metadata.html
Normal file
200
templates/wiki/plugins/parks/park_metadata.html
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
{% extends "wiki/article.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
{% load wiki_tags %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block wiki_contents_tab %}
|
||||||
|
<div class="bg-white rounded-lg shadow-md p-6">
|
||||||
|
<h2 class="text-2xl font-bold mb-6">{% trans "Park Metadata" %}</h2>
|
||||||
|
|
||||||
|
<form method="POST" class="space-y-6">
|
||||||
|
{% csrf_token %}
|
||||||
|
|
||||||
|
<!-- Basic Information -->
|
||||||
|
<div class="bg-gray-50 p-4 rounded-lg">
|
||||||
|
<h3 class="text-lg font-semibold mb-4">{% trans "Basic Information" %}</h3>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div class="form-group">
|
||||||
|
{{ form.operator.label_tag }}
|
||||||
|
{{ form.operator }}
|
||||||
|
{{ form.operator.errors }}
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
{{ form.owner.label_tag }}
|
||||||
|
{{ form.owner }}
|
||||||
|
{{ form.owner.errors }}
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
{{ form.opened_date.label_tag }}
|
||||||
|
{{ form.opened_date }}
|
||||||
|
{{ form.opened_date.errors }}
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
{{ form.park_size.label_tag }}
|
||||||
|
{{ form.park_size }}
|
||||||
|
{{ form.park_size.errors }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Location Information -->
|
||||||
|
<div class="bg-gray-50 p-4 rounded-lg">
|
||||||
|
<h3 class="text-lg font-semibold mb-4">{% trans "Location" %}</h3>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div class="form-group">
|
||||||
|
{{ form.latitude.label_tag }}
|
||||||
|
{{ form.latitude }}
|
||||||
|
{{ form.latitude.errors }}
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
{{ form.longitude.label_tag }}
|
||||||
|
{{ form.longitude }}
|
||||||
|
{{ form.longitude.errors }}
|
||||||
|
</div>
|
||||||
|
<div class="form-group col-span-2">
|
||||||
|
{{ form.address.label_tag }}
|
||||||
|
{{ form.address }}
|
||||||
|
{{ form.address.errors }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Operating Information -->
|
||||||
|
<div class="bg-gray-50 p-4 rounded-lg">
|
||||||
|
<h3 class="text-lg font-semibold mb-4">{% trans "Operating Information" %}</h3>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div class="form-group">
|
||||||
|
{{ form.seasonal.label_tag }}
|
||||||
|
{{ form.seasonal }}
|
||||||
|
{{ form.seasonal.errors }}
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
{{ form.season_start.label_tag }}
|
||||||
|
{{ form.season_start }}
|
||||||
|
{{ form.season_start.errors }}
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
{{ form.season_end.label_tag }}
|
||||||
|
{{ form.season_end }}
|
||||||
|
{{ form.season_end.errors }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Attractions -->
|
||||||
|
<div class="bg-gray-50 p-4 rounded-lg">
|
||||||
|
<h3 class="text-lg font-semibold mb-4">{% trans "Attractions" %}</h3>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div class="form-group">
|
||||||
|
{{ form.total_rides.label_tag }}
|
||||||
|
{{ form.total_rides }}
|
||||||
|
{{ form.total_rides.errors }}
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
{{ form.total_roller_coasters.label_tag }}
|
||||||
|
{{ form.total_roller_coasters }}
|
||||||
|
{{ form.total_roller_coasters.errors }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Contact Information -->
|
||||||
|
<div class="bg-gray-50 p-4 rounded-lg">
|
||||||
|
<h3 class="text-lg font-semibold mb-4">{% trans "Contact Information" %}</h3>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div class="form-group">
|
||||||
|
{{ form.phone.label_tag }}
|
||||||
|
{{ form.phone }}
|
||||||
|
{{ form.phone.errors }}
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
{{ form.email.label_tag }}
|
||||||
|
{{ form.email }}
|
||||||
|
{{ form.email.errors }}
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
{{ form.website.label_tag }}
|
||||||
|
{{ form.website }}
|
||||||
|
{{ form.website.errors }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Social Media -->
|
||||||
|
<div class="bg-gray-50 p-4 rounded-lg">
|
||||||
|
<h3 class="text-lg font-semibold mb-4">{% trans "Social Media" %}</h3>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div class="form-group">
|
||||||
|
{{ form.facebook.label_tag }}
|
||||||
|
{{ form.facebook }}
|
||||||
|
{{ form.facebook.errors }}
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
{{ form.twitter.label_tag }}
|
||||||
|
{{ form.twitter }}
|
||||||
|
{{ form.twitter.errors }}
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
{{ form.instagram.label_tag }}
|
||||||
|
{{ form.instagram }}
|
||||||
|
{{ form.instagram.errors }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Additional Information -->
|
||||||
|
<div class="bg-gray-50 p-4 rounded-lg">
|
||||||
|
<h3 class="text-lg font-semibold mb-4">{% trans "Additional Information" %}</h3>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="form-group">
|
||||||
|
{{ form.amenities_text.label_tag }}
|
||||||
|
{{ form.amenities_text }}
|
||||||
|
{{ form.amenities_text.errors }}
|
||||||
|
<p class="text-sm text-gray-600 mt-1">{{ form.amenities_text.help_text }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
{{ form.ticket_info_text.label_tag }}
|
||||||
|
{{ form.ticket_info_text }}
|
||||||
|
{{ form.ticket_info_text.errors }}
|
||||||
|
<p class="text-sm text-gray-600 mt-1">{{ form.ticket_info_text.help_text }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Submit Button -->
|
||||||
|
<div class="flex justify-end space-x-4">
|
||||||
|
<a href="{% url 'wiki:get' path=article.get_absolute_url %}"
|
||||||
|
class="px-4 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50">
|
||||||
|
{% trans "Cancel" %}
|
||||||
|
</a>
|
||||||
|
<button type="submit"
|
||||||
|
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">
|
||||||
|
{% trans "Save Changes" %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block wiki_footer_script %}
|
||||||
|
{{ block.super }}
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// Handle seasonal checkbox toggling season dates
|
||||||
|
const seasonalCheckbox = document.getElementById('id_seasonal');
|
||||||
|
const seasonStartInput = document.getElementById('id_season_start');
|
||||||
|
const seasonEndInput = document.getElementById('id_season_end');
|
||||||
|
|
||||||
|
function toggleSeasonDates() {
|
||||||
|
const isDisabled = !seasonalCheckbox.checked;
|
||||||
|
seasonStartInput.disabled = isDisabled;
|
||||||
|
seasonEndInput.disabled = isDisabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seasonalCheckbox) {
|
||||||
|
toggleSeasonDates();
|
||||||
|
seasonalCheckbox.addEventListener('change', toggleSeasonDates);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
146
templates/wiki/plugins/parks/park_statistics.html
Normal file
146
templates/wiki/plugins/parks/park_statistics.html
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
{% extends "wiki/article.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
{% load wiki_tags %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block wiki_contents_tab %}
|
||||||
|
<div class="bg-white rounded-lg shadow-md p-6">
|
||||||
|
<h2 class="text-2xl font-bold mb-6">{% trans "Park Statistics" %}</h2>
|
||||||
|
|
||||||
|
<!-- Add New Statistics -->
|
||||||
|
<div class="mb-8">
|
||||||
|
<h3 class="text-lg font-semibold mb-4">{% trans "Add New Statistics" %}</h3>
|
||||||
|
<form method="POST" class="bg-gray-50 p-4 rounded-lg">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
|
<div class="form-group">
|
||||||
|
{{ form.year.label_tag }}
|
||||||
|
{{ form.year }}
|
||||||
|
{{ form.year.errors }}
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
{{ form.attendance.label_tag }}
|
||||||
|
{{ form.attendance }}
|
||||||
|
{{ form.attendance.errors }}
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
{{ form.revenue.label_tag }}
|
||||||
|
{{ form.revenue }}
|
||||||
|
{{ form.revenue.errors }}
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
{{ form.investment.label_tag }}
|
||||||
|
{{ form.investment }}
|
||||||
|
{{ form.investment.errors }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 flex justify-end">
|
||||||
|
<button type="submit"
|
||||||
|
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">
|
||||||
|
{% trans "Add Statistics" %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Statistics History -->
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-semibold mb-4">{% trans "Historical Statistics" %}</h3>
|
||||||
|
{% if statistics %}
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead class="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
{% trans "Year" %}
|
||||||
|
</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
{% trans "Attendance" %}
|
||||||
|
</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
{% trans "Revenue" %}
|
||||||
|
</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
{% trans "Investment" %}
|
||||||
|
</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
{% trans "Actions" %}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="bg-white divide-y divide-gray-200">
|
||||||
|
{% for stat in statistics %}
|
||||||
|
<tr>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||||
|
{{ stat.year }}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||||
|
{{ stat.attendance|default:"-" }}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||||
|
{% if stat.revenue %}
|
||||||
|
${{ stat.revenue }}
|
||||||
|
{% else %}
|
||||||
|
-
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||||
|
{% if stat.investment %}
|
||||||
|
${{ stat.investment }}
|
||||||
|
{% else %}
|
||||||
|
-
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||||
|
<form method="POST" action="{% url 'wiki:parks_delete_statistic' article.id stat.id %}"
|
||||||
|
class="inline-block">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit"
|
||||||
|
class="text-red-600 hover:text-red-900"
|
||||||
|
onclick="return confirm('{% trans "Are you sure you want to delete this statistic?" %}')">
|
||||||
|
{% trans "Delete" %}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-gray-500 italic">{% trans "No statistics available." %}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Back to Article -->
|
||||||
|
<div class="mt-8">
|
||||||
|
<a href="{% url 'wiki:get' path=article.get_absolute_url %}"
|
||||||
|
class="inline-block px-4 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50">
|
||||||
|
{% trans "Back to Article" %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block wiki_footer_script %}
|
||||||
|
{{ block.super }}
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// Auto-fill current year if empty
|
||||||
|
const yearInput = document.getElementById('id_year');
|
||||||
|
if (yearInput && !yearInput.value) {
|
||||||
|
yearInput.value = new Date().getFullYear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format number inputs
|
||||||
|
const numberInputs = document.querySelectorAll('input[type="number"]');
|
||||||
|
numberInputs.forEach(input => {
|
||||||
|
input.addEventListener('blur', function() {
|
||||||
|
if (this.value) {
|
||||||
|
this.value = parseInt(this.value).toLocaleString();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
146
templates/wiki/plugins/parks/sidebar.html
Normal file
146
templates/wiki/plugins/parks/sidebar.html
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
{% load i18n %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
<div class="park-sidebar">
|
||||||
|
<!-- Quick Stats -->
|
||||||
|
<div class="bg-gray-50 p-4 rounded-lg mb-4">
|
||||||
|
{% if article.park_metadata %}
|
||||||
|
<div class="space-y-3">
|
||||||
|
{% if article.park_metadata.operator %}
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-sm text-gray-600">{% trans "Operator" %}</span>
|
||||||
|
<span class="text-sm font-medium">{{ article.park_metadata.operator }}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if article.park_metadata.opened_date %}
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-sm text-gray-600">{% trans "Opened" %}</span>
|
||||||
|
<span class="text-sm font-medium">{{ article.park_metadata.opened_date|date:"Y" }}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if article.park_metadata.total_rides %}
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-sm text-gray-600">{% trans "Total Rides" %}</span>
|
||||||
|
<span class="text-sm font-medium">{{ article.park_metadata.total_rides }}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if article.park_metadata.total_roller_coasters %}
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-sm text-gray-600">{% trans "Roller Coasters" %}</span>
|
||||||
|
<span class="text-sm font-medium">{{ article.park_metadata.total_roller_coasters }}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if article.park_metadata.park_size %}
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-sm text-gray-600">{% trans "Size" %}</span>
|
||||||
|
<span class="text-sm font-medium">{{ article.park_metadata.park_size }} {% trans "acres" %}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Season Info -->
|
||||||
|
{% if article.park_metadata.seasonal %}
|
||||||
|
<div class="mt-4 pt-4 border-t border-gray-200">
|
||||||
|
<h4 class="text-sm font-medium text-gray-900 mb-2">{% trans "Season" %}</h4>
|
||||||
|
<div class="text-sm text-gray-600">
|
||||||
|
{% if article.park_metadata.season_start and article.park_metadata.season_end %}
|
||||||
|
{{ article.park_metadata.season_start|date:"M j" }} - {{ article.park_metadata.season_end|date:"M j" }}
|
||||||
|
{% else %}
|
||||||
|
{% trans "Seasonal operation" %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Contact -->
|
||||||
|
{% if article.park_metadata.phone or article.park_metadata.email or article.park_metadata.website %}
|
||||||
|
<div class="mt-4 pt-4 border-t border-gray-200">
|
||||||
|
<h4 class="text-sm font-medium text-gray-900 mb-2">{% trans "Contact" %}</h4>
|
||||||
|
<div class="space-y-2">
|
||||||
|
{% if article.park_metadata.phone %}
|
||||||
|
<div class="text-sm">
|
||||||
|
<a href="tel:{{ article.park_metadata.phone }}"
|
||||||
|
class="text-blue-600 hover:text-blue-800">
|
||||||
|
{{ article.park_metadata.phone }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if article.park_metadata.website %}
|
||||||
|
<div class="text-sm">
|
||||||
|
<a href="{{ article.park_metadata.website }}"
|
||||||
|
class="text-blue-600 hover:text-blue-800"
|
||||||
|
target="_blank" rel="noopener">
|
||||||
|
{% trans "Official Website" %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Social Media -->
|
||||||
|
{% if article.park_metadata.facebook or article.park_metadata.twitter or article.park_metadata.instagram %}
|
||||||
|
<div class="mt-4 pt-4 border-t border-gray-200">
|
||||||
|
<h4 class="text-sm font-medium text-gray-900 mb-2">{% trans "Social Media" %}</h4>
|
||||||
|
<div class="flex space-x-4">
|
||||||
|
{% if article.park_metadata.facebook %}
|
||||||
|
<a href="{{ article.park_metadata.facebook }}"
|
||||||
|
class="text-gray-400 hover:text-blue-600"
|
||||||
|
target="_blank" rel="noopener">
|
||||||
|
<i class="fab fa-facebook"></i>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if article.park_metadata.twitter %}
|
||||||
|
<a href="{{ article.park_metadata.twitter }}"
|
||||||
|
class="text-gray-400 hover:text-blue-400"
|
||||||
|
target="_blank" rel="noopener">
|
||||||
|
<i class="fab fa-twitter"></i>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if article.park_metadata.instagram %}
|
||||||
|
<a href="{{ article.park_metadata.instagram }}"
|
||||||
|
class="text-gray-400 hover:text-pink-600"
|
||||||
|
target="_blank" rel="noopener">
|
||||||
|
<i class="fab fa-instagram"></i>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
<p class="text-sm text-gray-500 italic">
|
||||||
|
{% trans "No park metadata available." %}
|
||||||
|
{% if article|can_write:user %}
|
||||||
|
<a href="{% url 'wiki:parks_metadata' article.id %}"
|
||||||
|
class="text-blue-600 hover:text-blue-800">
|
||||||
|
{% trans "Add metadata" %}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Admin Actions -->
|
||||||
|
{% if article|can_write:user %}
|
||||||
|
<div class="space-y-2">
|
||||||
|
<a href="{% url 'wiki:parks_metadata' article.id %}"
|
||||||
|
class="block w-full px-4 py-2 text-sm text-center bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200">
|
||||||
|
{% trans "Edit Park Information" %}
|
||||||
|
</a>
|
||||||
|
{% if article.park_metadata %}
|
||||||
|
<a href="{% url 'wiki:parks_statistics' article.id %}"
|
||||||
|
class="block w-full px-4 py-2 text-sm text-center bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200">
|
||||||
|
{% trans "Manage Statistics" %}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
126
templates/wiki/rides/ride_article.html
Normal file
126
templates/wiki/rides/ride_article.html
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
{% extends "wiki/base.html" %}
|
||||||
|
{% load wiki_tags %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block wiki_header_title %}
|
||||||
|
{{ article.current_revision.title }}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block wiki_content %}
|
||||||
|
<article class="ride-article">
|
||||||
|
<!-- Ride Header -->
|
||||||
|
<div class="mb-8">
|
||||||
|
{% if article.image %}
|
||||||
|
<div class="mb-4">
|
||||||
|
<img src="{{ article.image.url }}" alt="{{ article.current_revision.title }}"
|
||||||
|
class="w-full h-64 object-cover rounded-lg shadow-md">
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Ride Quick Info -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 bg-gray-50 p-4 rounded-lg">
|
||||||
|
{% if article.metadata.park %}
|
||||||
|
<div class="ride-info-item">
|
||||||
|
<span class="text-gray-600 font-medium">Park:</span>
|
||||||
|
<a href="{{ article.metadata.park.get_absolute_url }}"
|
||||||
|
class="text-blue-600 hover:text-blue-800">
|
||||||
|
{{ article.metadata.park.name }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if article.metadata.opened %}
|
||||||
|
<div class="ride-info-item">
|
||||||
|
<span class="text-gray-600 font-medium">Opened:</span>
|
||||||
|
<span class="text-gray-900">{{ article.metadata.opened }}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if article.metadata.manufacturer %}
|
||||||
|
<div class="ride-info-item">
|
||||||
|
<span class="text-gray-600 font-medium">Manufacturer:</span>
|
||||||
|
<span class="text-gray-900">{{ article.metadata.manufacturer }}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if article.metadata.type %}
|
||||||
|
<div class="ride-info-item">
|
||||||
|
<span class="text-gray-600 font-medium">Type:</span>
|
||||||
|
<span class="text-gray-900">{{ article.metadata.type }}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Ride Content -->
|
||||||
|
<div class="ride-content prose max-w-none">
|
||||||
|
{{ article.render|safe }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Technical Specifications -->
|
||||||
|
{% if article.metadata.specs %}
|
||||||
|
<div class="mt-8 bg-gray-50 rounded-lg p-6">
|
||||||
|
<h2 class="text-2xl font-bold text-gray-900 mb-4">Technical Specifications</h2>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{% for spec, value in article.metadata.specs.items %}
|
||||||
|
<div class="spec-item">
|
||||||
|
<span class="text-gray-600 font-medium">{{ spec|title }}:</span>
|
||||||
|
<span class="text-gray-900">{{ value }}</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Records and Statistics -->
|
||||||
|
{% if article.metadata.records %}
|
||||||
|
<div class="mt-8">
|
||||||
|
<h2 class="text-2xl font-bold text-gray-900 mb-4">Records & Achievements</h2>
|
||||||
|
<div class="bg-white rounded-lg shadow-md p-6">
|
||||||
|
<ul class="space-y-3">
|
||||||
|
{% for record in article.metadata.records %}
|
||||||
|
<li class="flex items-start">
|
||||||
|
<svg class="w-6 h-6 text-blue-600 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||||
|
</svg>
|
||||||
|
<span>{{ record }}</span>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</article>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block wiki_sidebar %}
|
||||||
|
{{ block.super }}
|
||||||
|
<!-- Additional ride-specific sidebar content -->
|
||||||
|
<div class="mt-6">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 mb-3">Quick Links</h3>
|
||||||
|
<ul class="space-y-2">
|
||||||
|
<li><a href="#specifications" class="text-gray-600 hover:text-blue-600">Specifications</a></li>
|
||||||
|
<li><a href="#history" class="text-gray-600 hover:text-blue-600">History</a></li>
|
||||||
|
<li><a href="#experience" class="text-gray-600 hover:text-blue-600">Ride Experience</a></li>
|
||||||
|
<li><a href="#records" class="text-gray-600 hover:text-blue-600">Records</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Related Rides -->
|
||||||
|
{% if article.related_articles.similar_rides %}
|
||||||
|
<div class="mt-6">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 mb-3">Similar Rides</h3>
|
||||||
|
<ul class="space-y-2">
|
||||||
|
{% for ride in article.related_articles.similar_rides %}
|
||||||
|
<li>
|
||||||
|
<a href="{{ ride.get_absolute_url }}"
|
||||||
|
class="text-gray-600 hover:text-blue-600">
|
||||||
|
{{ ride.title }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
@@ -1,223 +1,120 @@
|
|||||||
"""
|
from django.conf import settings as django_settings
|
||||||
Django settings for thrillwiki project.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
import os
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
SECRET_KEY = "django-insecure-=0)^0#h#k$0@$8$ys=^$0#h#k$0@$8$ys=^"
|
|
||||||
|
|
||||||
# SECURITY WARNING: don't run with debug turned on in production!
|
# Quick-start development settings - unsuitable for production
|
||||||
|
SECRET_KEY = 'django-insecure-key-for-development'
|
||||||
DEBUG = True
|
DEBUG = True
|
||||||
|
ALLOWED_HOSTS = []
|
||||||
CSRF_TRUSTED_ORIGINS = ["https://beta.thrillwiki.com"]
|
|
||||||
ALLOWED_HOSTS = ["*"]
|
|
||||||
|
|
||||||
# GeoDjango Settings
|
|
||||||
GDAL_LIBRARY_PATH = "/opt/homebrew/lib/libgdal.dylib"
|
|
||||||
GEOS_LIBRARY_PATH = "/opt/homebrew/lib/libgeos_c.dylib"
|
|
||||||
|
|
||||||
# Application definition
|
# Application definition
|
||||||
INSTALLED_APPS = [
|
INSTALLED_APPS = [
|
||||||
"django.contrib.admin",
|
'django.contrib.admin',
|
||||||
"django.contrib.auth",
|
'django.contrib.auth',
|
||||||
"django.contrib.contenttypes",
|
'django.contrib.contenttypes',
|
||||||
"django.contrib.sessions",
|
'django.contrib.sessions',
|
||||||
"django.contrib.messages",
|
'django.contrib.messages',
|
||||||
"django.contrib.staticfiles",
|
'django.contrib.staticfiles',
|
||||||
"django.contrib.sites",
|
'django.contrib.sites.apps.SitesConfig',
|
||||||
"django.contrib.gis", # Add GeoDjango
|
'django.contrib.humanize.apps.HumanizeConfig',
|
||||||
"pghistory", # Add django-pghistory
|
'django_nyt.apps.DjangoNytConfig',
|
||||||
"pgtrigger", # Required by django-pghistory
|
'mptt',
|
||||||
"history.apps.HistoryConfig", # History timeline app
|
'sorl.thumbnail',
|
||||||
"allauth",
|
'wiki.apps.WikiConfig', # Main wiki app
|
||||||
"allauth.account",
|
'wiki.plugins.parks.apps.ParksPluginConfig', # Parks plugin
|
||||||
"allauth.socialaccount",
|
|
||||||
"allauth.socialaccount.providers.google",
|
|
||||||
"allauth.socialaccount.providers.discord",
|
|
||||||
"django_cleanup",
|
|
||||||
"django_filters",
|
|
||||||
"django_htmx",
|
|
||||||
"whitenoise",
|
|
||||||
"django_tailwind_cli",
|
|
||||||
"core",
|
|
||||||
"accounts",
|
|
||||||
"companies",
|
|
||||||
"parks",
|
|
||||||
"rides",
|
|
||||||
"reviews",
|
|
||||||
"email_service",
|
|
||||||
"media.apps.MediaConfig",
|
|
||||||
"moderation",
|
|
||||||
"history_tracking",
|
|
||||||
"designers",
|
|
||||||
"analytics",
|
|
||||||
"location",
|
|
||||||
"search.apps.SearchConfig", # Add search app
|
|
||||||
]
|
]
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
"django.middleware.cache.UpdateCacheMiddleware",
|
'django.middleware.security.SecurityMiddleware',
|
||||||
"django.middleware.security.SecurityMiddleware",
|
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||||
"whitenoise.middleware.WhiteNoiseMiddleware",
|
'django.middleware.common.CommonMiddleware',
|
||||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
'django.middleware.csrf.CsrfViewMiddleware',
|
||||||
"django.middleware.common.CommonMiddleware",
|
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||||
"django.middleware.csrf.CsrfViewMiddleware",
|
'django.contrib.messages.middleware.MessageMiddleware',
|
||||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||||
"django.contrib.messages.middleware.MessageMiddleware",
|
|
||||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
|
||||||
"core.middleware.PgHistoryContextMiddleware", # Add history context tracking
|
|
||||||
"allauth.account.middleware.AccountMiddleware",
|
|
||||||
"django.middleware.cache.FetchFromCacheMiddleware",
|
|
||||||
"django_htmx.middleware.HtmxMiddleware",
|
|
||||||
"analytics.middleware.PageViewMiddleware", # Add our page view tracking
|
|
||||||
]
|
]
|
||||||
|
|
||||||
ROOT_URLCONF = "thrillwiki.urls"
|
ROOT_URLCONF = 'thrillwiki.urls'
|
||||||
|
|
||||||
TEMPLATES = [
|
TEMPLATES = [
|
||||||
{
|
{
|
||||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||||
"DIRS": [os.path.join(BASE_DIR, "templates")],
|
'DIRS': [BASE_DIR / 'templates'],
|
||||||
"APP_DIRS": True,
|
'APP_DIRS': True,
|
||||||
"OPTIONS": {
|
'OPTIONS': {
|
||||||
"context_processors": [
|
'context_processors': [
|
||||||
"django.template.context_processors.debug",
|
'django.template.context_processors.debug',
|
||||||
"django.template.context_processors.request",
|
'django.template.context_processors.request',
|
||||||
"django.contrib.auth.context_processors.auth",
|
'django.contrib.auth.context_processors.auth',
|
||||||
"django.contrib.messages.context_processors.messages",
|
'django.contrib.messages.context_processors.messages',
|
||||||
"moderation.context_processors.moderation_access",
|
'django.template.context_processors.media',
|
||||||
]
|
'django.template.context_processors.static',
|
||||||
}
|
'django.template.context_processors.tz',
|
||||||
}
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
WSGI_APPLICATION = "thrillwiki.wsgi.application"
|
WSGI_APPLICATION = 'thrillwiki.wsgi.application'
|
||||||
|
|
||||||
# Database
|
|
||||||
DATABASES = {
|
DATABASES = {
|
||||||
"default": {
|
'default': {
|
||||||
"ENGINE": "django.contrib.gis.db.backends.postgis", # Update to use PostGIS
|
'ENGINE': 'django.db.backends.postgresql',
|
||||||
"NAME": "thrillwiki",
|
'NAME': 'thrillwiki',
|
||||||
"USER": "wiki",
|
'USER': 'postgres',
|
||||||
"PASSWORD": "thrillwiki",
|
'PASSWORD': 'postgres',
|
||||||
"HOST": "192.168.86.3",
|
'HOST': 'localhost',
|
||||||
"PORT": "5432",
|
'PORT': '5432',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# Cache settings
|
|
||||||
CACHES = {
|
|
||||||
"default": {
|
|
||||||
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
|
|
||||||
"LOCATION": "unique-snowflake",
|
|
||||||
"TIMEOUT": 300, # 5 minutes
|
|
||||||
"OPTIONS": {"MAX_ENTRIES": 1000},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
CACHE_MIDDLEWARE_SECONDS = 1 # 5 minutes
|
|
||||||
CACHE_MIDDLEWARE_KEY_PREFIX = "thrillwiki"
|
|
||||||
|
|
||||||
# Password validation
|
|
||||||
AUTH_PASSWORD_VALIDATORS = [
|
AUTH_PASSWORD_VALIDATORS = [
|
||||||
{
|
{
|
||||||
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
|
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
|
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
|
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
|
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Wiki settings
|
||||||
|
WIKI_ACCOUNT_HANDLING = True
|
||||||
|
WIKI_ACCOUNT_SIGNUP_ALLOWED = True
|
||||||
|
WIKI_ANONYMOUS = True
|
||||||
|
WIKI_ANONYMOUS_WRITE = False
|
||||||
|
WIKI_MARKDOWN_HTML_ATTRIBUTES = True
|
||||||
|
WIKI_MARKDOWN_HTML_STYLES = True
|
||||||
|
|
||||||
|
SITE_ID = 1
|
||||||
|
|
||||||
# Internationalization
|
# Internationalization
|
||||||
LANGUAGE_CODE = "en-us"
|
LANGUAGE_CODE = 'en-us'
|
||||||
TIME_ZONE = "America/New_York"
|
TIME_ZONE = 'UTC'
|
||||||
USE_I18N = True
|
USE_I18N = True
|
||||||
USE_TZ = True
|
USE_TZ = True
|
||||||
|
|
||||||
# Static files (CSS JavaScript Images)
|
# Static files (CSS, JavaScript, Images)
|
||||||
STATIC_URL = "static/"
|
STATIC_URL = 'static/'
|
||||||
STATICFILES_DIRS = [BASE_DIR / "static"]
|
STATIC_ROOT = BASE_DIR / 'staticfiles'
|
||||||
STATIC_ROOT = os.path.join(BASE_DIR, "staticfiles")
|
STATICFILES_DIRS = [
|
||||||
|
BASE_DIR / 'static',
|
||||||
# Media files
|
|
||||||
MEDIA_URL = "/media/"
|
|
||||||
MEDIA_ROOT = os.path.join(BASE_DIR, "media")
|
|
||||||
|
|
||||||
# Default primary key field type
|
|
||||||
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
|
||||||
|
|
||||||
# Authentication settings
|
|
||||||
AUTHENTICATION_BACKENDS = [
|
|
||||||
"django.contrib.auth.backends.ModelBackend",
|
|
||||||
"allauth.account.auth_backends.AuthenticationBackend",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
# django-allauth settings
|
# Media files
|
||||||
SITE_ID = 1
|
MEDIA_URL = '/media/'
|
||||||
ACCOUNT_EMAIL_REQUIRED = True
|
MEDIA_ROOT = BASE_DIR / 'uploads'
|
||||||
ACCOUNT_USERNAME_REQUIRED = True
|
|
||||||
ACCOUNT_LOGIN_METHODS = {'email', 'username'}
|
|
||||||
ACCOUNT_EMAIL_VERIFICATION = "optional"
|
|
||||||
LOGIN_REDIRECT_URL = "/"
|
|
||||||
ACCOUNT_LOGOUT_REDIRECT_URL = "/"
|
|
||||||
|
|
||||||
# Custom adapters
|
# Default primary key field type
|
||||||
ACCOUNT_ADAPTER = "accounts.adapters.CustomAccountAdapter"
|
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
||||||
SOCIALACCOUNT_ADAPTER = "accounts.adapters.CustomSocialAccountAdapter"
|
|
||||||
|
|
||||||
# Social account settings
|
|
||||||
SOCIALACCOUNT_PROVIDERS = {
|
|
||||||
"google": {
|
|
||||||
"APP": {
|
|
||||||
"client_id": "135166769591-nopcgmo0fkqfqfs9qe783a137mtmcrt2.apps.googleusercontent.com",
|
|
||||||
"[SECRET-REMOVED]",
|
|
||||||
"key": "",
|
|
||||||
},
|
|
||||||
"SCOPE": [
|
|
||||||
"profile",
|
|
||||||
"email",
|
|
||||||
],
|
|
||||||
"AUTH_PARAMS": {"access_type": "online"},
|
|
||||||
},
|
|
||||||
"discord": {
|
|
||||||
"APP": {
|
|
||||||
"client_id": "1299112802274902047",
|
|
||||||
"[SECRET-REMOVED]",
|
|
||||||
"key": "",
|
|
||||||
},
|
|
||||||
"SCOPE": ["identify", "email"],
|
|
||||||
"OAUTH_PKCE_ENABLED": True,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Additional social account settings
|
|
||||||
SOCIALACCOUNT_LOGIN_ON_GET = True
|
|
||||||
SOCIALACCOUNT_AUTO_SIGNUP = False
|
|
||||||
SOCIALACCOUNT_STORE_TOKENS = True
|
|
||||||
|
|
||||||
# Email settings
|
# Email settings
|
||||||
EMAIL_BACKEND = "email_service.backends.ForwardEmailBackend"
|
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
|
||||||
FORWARD_EMAIL_BASE_URL = "https://api.forwardemail.net"
|
|
||||||
SERVER_EMAIL = "django_webmaster@thrillwiki.com"
|
|
||||||
|
|
||||||
# Custom User Model
|
|
||||||
AUTH_USER_MODEL = "accounts.User"
|
|
||||||
|
|
||||||
# Tailwind configuration
|
|
||||||
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_DIST_CSS = os.path.join(BASE_DIR, "static/css/tailwind.css")
|
|
||||||
|
|
||||||
# Cloudflare Turnstile settings
|
|
||||||
TURNSTILE_SITE_KEY = "0x4AAAAAAAyqVp3RjccrC9Kz"
|
|
||||||
TURNSTILE_SECRET_KEY = "0x4AAAAAAAyqVrQolYsrAFGJ39PXHJ_HQzY"
|
|
||||||
TURNSTILE_VERIFY_URL = "https://challenges.cloudflare.com/turnstile/v0/siteverify"
|
|
||||||
|
|||||||
@@ -1,82 +1,6 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.urls import path, include
|
from django.urls import path, include
|
||||||
from django.conf import settings
|
|
||||||
from django.conf.urls.static import static
|
|
||||||
from django.views.static import serve
|
|
||||||
from accounts import views as accounts_views
|
|
||||||
from django.views.generic import TemplateView
|
|
||||||
from .views import HomeView, SearchView
|
|
||||||
from . import views
|
|
||||||
import os
|
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("admin/", admin.site.urls),
|
path('admin/', admin.site.urls),
|
||||||
# Main app URLs
|
|
||||||
path("", HomeView.as_view(), name="home"),
|
|
||||||
# Parks and Rides URLs
|
|
||||||
path("parks/", include("parks.urls", namespace="parks")),
|
|
||||||
# Global rides URLs
|
|
||||||
path("rides/", include("rides.urls", namespace="rides")),
|
|
||||||
# Other URLs
|
|
||||||
path("reviews/", include("reviews.urls")),
|
|
||||||
path("companies/", include("companies.urls")),
|
|
||||||
path("designers/", include("designers.urls", namespace="designers")),
|
|
||||||
path("photos/", include("media.urls", namespace="photos")), # Add photos URLs
|
|
||||||
path("search/", SearchView.as_view(), name="search"),
|
|
||||||
path(
|
|
||||||
"terms/", TemplateView.as_view(template_name="pages/terms.html"), name="terms"
|
|
||||||
),
|
|
||||||
path(
|
|
||||||
"privacy/",
|
|
||||||
TemplateView.as_view(template_name="pages/privacy.html"),
|
|
||||||
name="privacy",
|
|
||||||
),
|
|
||||||
# Custom authentication URLs first (to override allauth defaults)
|
|
||||||
path("accounts/", include("accounts.urls")),
|
|
||||||
# Default allauth URLs (for social auth and other features)
|
|
||||||
path("accounts/", include("allauth.urls")),
|
|
||||||
path(
|
|
||||||
"accounts/email-required/", accounts_views.email_required, name="email_required"
|
|
||||||
),
|
|
||||||
# User profile URLs
|
|
||||||
path(
|
|
||||||
"user/<str:username>/",
|
|
||||||
accounts_views.ProfileView.as_view(),
|
|
||||||
name="user_profile",
|
|
||||||
),
|
|
||||||
path(
|
|
||||||
"profile/<str:username>/", accounts_views.ProfileView.as_view(), name="profile"
|
|
||||||
),
|
|
||||||
path("settings/", accounts_views.SettingsView.as_view(), name="settings"),
|
|
||||||
# Redirect /user/ to the user's profile if logged in
|
|
||||||
path("user/", accounts_views.user_redirect_view, name="user_redirect"),
|
|
||||||
# Moderation URLs - placed after other URLs but before static/media serving
|
|
||||||
path("moderation/", include("moderation.urls", namespace="moderation")),
|
|
||||||
path("history/", include("history.urls", namespace="history")),
|
|
||||||
path(
|
|
||||||
"env-settings/",
|
|
||||||
views***REMOVED***ironment_and_settings_view,
|
|
||||||
name="environment_and_settings",
|
|
||||||
),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
# Serve static files in development
|
|
||||||
if settings.DEBUG:
|
|
||||||
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
|
|
||||||
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
|
||||||
|
|
||||||
# Serve test coverage reports in development
|
|
||||||
coverage_dir = os.path.join(settings.BASE_DIR, 'tests', 'coverage_html')
|
|
||||||
if os.path.exists(coverage_dir):
|
|
||||||
urlpatterns += [
|
|
||||||
path('coverage/', serve, {
|
|
||||||
'document_root': coverage_dir,
|
|
||||||
'path': 'index.html'
|
|
||||||
}),
|
|
||||||
path('coverage/<path:path>', serve, {
|
|
||||||
'document_root': coverage_dir,
|
|
||||||
}),
|
|
||||||
]
|
|
||||||
|
|
||||||
handler404 = "thrillwiki.views.handler404"
|
|
||||||
handler500 = "thrillwiki.views.handler500"
|
|
||||||
|
|||||||
190
uv.lock
generated
190
uv.lock
generated
@@ -1,4 +1,5 @@
|
|||||||
version = 1
|
version = 1
|
||||||
|
revision = 1
|
||||||
requires-python = ">=3.13"
|
requires-python = ">=3.13"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -63,6 +64,23 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]black-24.10.0-py3-none-any.whl", hash = "sha256:[AWS-SECRET-REMOVED]9853e47a294a3dd963c1dd7d", size = 206898 },
|
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]black-24.10.0-py3-none-any.whl", hash = "sha256:[AWS-SECRET-REMOVED]9853e47a294a3dd963c1dd7d", size = 206898 },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bleach"
|
||||||
|
version = "6.2.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "webencodings" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]bleach-6.2.0.tar.gz", hash = "sha256:[AWS-SECRET-REMOVED]b1317a7e1ba69b56e95f991f", size = 203083 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]bleach-6.2.0-py3-none-any.whl", hash = "sha256:[AWS-SECRET-REMOVED]36f554da4e432fdd63f31e5e", size = 163406 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.optional-dependencies]
|
||||||
|
css = [
|
||||||
|
{ name = "tinycss2" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "certifi"
|
name = "certifi"
|
||||||
version = "2024.12.14"
|
version = "2024.12.14"
|
||||||
@@ -266,6 +284,18 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]django_allauth-65.3.0.tar.gz", hash = "sha256:[AWS-SECRET-REMOVED]a86d873a8a9fd8f0ec57bbbf", size = 1546784 }
|
sdist = { url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]django_allauth-65.3.0.tar.gz", hash = "sha256:[AWS-SECRET-REMOVED]a86d873a8a9fd8f0ec57bbbf", size = 1546784 }
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "django-classy-tags"
|
||||||
|
version = "4.1.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "django" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]django-classy-tags-4.1.0.tar.gz", hash = "sha256:[AWS-SECRET-REMOVED]bb750b2490a17b161774ee59", size = 24692 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]django_classy_tags-4.1.0-py3-none-any.whl", hash = "sha256:[AWS-SECRET-REMOVED]a160a847ff449588d4e01e55", size = 14095 },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "django-cleanup"
|
name = "django-cleanup"
|
||||||
version = "9.0.0"
|
version = "9.0.0"
|
||||||
@@ -313,6 +343,54 @@ 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]]
|
||||||
|
name = "django-js-asset"
|
||||||
|
version = "3.0.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "django" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]django_js_asset-3.0.1.tar.gz", hash = "sha256:[AWS-SECRET-REMOVED]2f6a6bfe93577dee793dc378", size = 7701 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]django_js_asset-3.0.1-py3-none-any.whl", hash = "sha256:[AWS-SECRET-REMOVED]43c282cb64fe6c13d7ca4c10", size = 4283 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "django-mptt"
|
||||||
|
version = "0.16.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "django-js-asset" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]django_mptt-0.16.0.tar.gz", hash = "sha256:[AWS-SECRET-REMOVED]999b10903b09de62bee84c8e", size = 69886 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]django_mptt-0.16.0-py3-none-any.whl", hash = "sha256:[AWS-SECRET-REMOVED]5f8472b690dbaf737d2af3b5", size = 115934 },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "django-nyt"
|
||||||
|
version = "1.4.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "django" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]django_nyt-1.4.1.tar.gz", hash = "sha256:[AWS-SECRET-REMOVED]1d987ee81bf6a0ac3352b4a1", size = 28960 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]django_nyt-1.4.1-py3-none-any.whl", hash = "sha256:[AWS-SECRET-REMOVED]47cdb2cc10e7c4f2fecd6aff", size = 41084 },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "django-oauth-toolkit"
|
name = "django-oauth-toolkit"
|
||||||
version = "3.0.1"
|
version = "3.0.1"
|
||||||
@@ -353,6 +431,19 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]django_pgtrigger-4.13.3-py3-none-any.whl", hash = "sha256:[AWS-SECRET-REMOVED]275d86ad756b90c307df3ca4", size = 34059 },
|
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]django_pgtrigger-4.13.3-py3-none-any.whl", hash = "sha256:[AWS-SECRET-REMOVED]275d86ad756b90c307df3ca4", size = 34059 },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "django-sekizai"
|
||||||
|
version = "4.1.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "django" },
|
||||||
|
{ name = "django-classy-tags" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]django-sekizai-4.1.0.tar.gz", hash = "sha256:[AWS-SECRET-REMOVED]3145bff11e58622fc653cdad", size = 14591 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]django_sekizai-4.1.0-py3-none-any.whl", hash = "sha256:[AWS-SECRET-REMOVED]9a1304a9b9e8b191229e2e4a", size = 8597 },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "django-simple-history"
|
name = "django-simple-history"
|
||||||
version = "3.7.0"
|
version = "3.7.0"
|
||||||
@@ -508,6 +599,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]jwcrypto-1.5.6-py3-none-any.whl", hash = "sha256:[AWS-SECRET-REMOVED]8be71f67f03566692fd55789", size = 92520 },
|
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]jwcrypto-1.5.6-py3-none-any.whl", hash = "sha256:[AWS-SECRET-REMOVED]8be71f67f03566692fd55789", size = 92520 },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "markdown"
|
||||||
|
version = "3.6"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]Markdown-3.6.tar.gz", hash = "sha256:[AWS-SECRET-REMOVED]a16cb35fa8ed8c2ddfad0224", size = 354715 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]Markdown-3.6-py3-none-any.whl", hash = "sha256:[AWS-SECRET-REMOVED]d40aa410fbc3b4ee832c850f", size = 105381 },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mccabe"
|
name = "mccabe"
|
||||||
version = "0.7.0"
|
version = "0.7.0"
|
||||||
@@ -732,6 +832,19 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:[AWS-SECRET-REMOVED]44f25406ffaebd50bd98dacb", size = 22997 },
|
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:[AWS-SECRET-REMOVED]44f25406ffaebd50bd98dacb", size = 22997 },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pymdown-extensions"
|
||||||
|
version = "10.5"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "markdown" },
|
||||||
|
{ name = "pyyaml" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]pymdown_extensions-10.5.tar.gz", hash = "sha256:[AWS-SECRET-REMOVED]a294de1989f29d20096cfd0b", size = 788318 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]pymdown_extensions-10.5-py3-none-any.whl", hash = "sha256:[AWS-SECRET-REMOVED]6ac4c5eb01e27464b80fe879", size = 241130 },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pyopenssl"
|
name = "pyopenssl"
|
||||||
version = "24.3.0"
|
version = "24.3.0"
|
||||||
@@ -820,6 +933,23 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]python_slugify-8.0.4-py2.py3-none-any.whl", hash = "sha256:[AWS-SECRET-REMOVED]5fd9e3a70164fc8c50faa6b8", size = 10051 },
|
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]python_slugify-8.0.4-py2.py3-none-any.whl", hash = "sha256:[AWS-SECRET-REMOVED]5fd9e3a70164fc8c50faa6b8", size = 10051 },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyyaml"
|
||||||
|
version = "6.0.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]pyyaml-6.0.2.tar.gz", hash = "sha256:[AWS-SECRET-REMOVED]e591037abe114850ff7bbc3e", size = 130631 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:[AWS-SECRET-REMOVED]c3170801852d752aa7a783ba", size = 181309 },
|
||||||
|
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:[AWS-SECRET-REMOVED]997de9efef88badc3bb9e2d1", size = 171679 },
|
||||||
|
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:[AWS-SECRET-REMOVED]0eef8c8f44e0254ab3b07133", size = 733428 },
|
||||||
|
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:[AWS-SECRET-REMOVED]73d41e99c4fff6b6c3276484", size = 763361 },
|
||||||
|
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:[AWS-SECRET-REMOVED]f1e08d9b561cb41b845f69d5", size = 759523 },
|
||||||
|
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:[AWS-SECRET-REMOVED]34e29c2a514c2c0c5fe971cc", size = 726660 },
|
||||||
|
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:[AWS-SECRET-REMOVED]899c72eacb5a668902e4d652", size = 751597 },
|
||||||
|
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:[AWS-SECRET-REMOVED]36abab80d4681424b84c1183", size = 140527 },
|
||||||
|
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:[AWS-SECRET-REMOVED]dd57cdeb95f3f2e085687563", size = 156446 },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "redis"
|
name = "redis"
|
||||||
version = "5.2.1"
|
version = "5.2.1"
|
||||||
@@ -877,6 +1007,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:[AWS-SECRET-REMOVED]56c89140852d1120324e8686", size = 9755 },
|
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:[AWS-SECRET-REMOVED]56c89140852d1120324e8686", size = 9755 },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sorl-thumbnail"
|
||||||
|
version = "12.11.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]sorl_thumbnail-12.11.0.tar.gz", hash = "sha256:[AWS-SECRET-REMOVED]f439b2e17b938b91eea463b3", size = 667102 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]sorl_thumbnail-12.11.0-py3-none-any.whl", hash = "sha256:[AWS-SECRET-REMOVED]4af5e9dc3f31cb605df765b5", size = 42789 },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sqlparse"
|
name = "sqlparse"
|
||||||
version = "0.5.3"
|
version = "0.5.3"
|
||||||
@@ -912,6 +1051,9 @@ 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-mptt" },
|
||||||
|
{ name = "django-nyt" },
|
||||||
{ name = "django-oauth-toolkit" },
|
{ name = "django-oauth-toolkit" },
|
||||||
{ name = "django-pghistory" },
|
{ name = "django-pghistory" },
|
||||||
{ name = "django-simple-history" },
|
{ name = "django-simple-history" },
|
||||||
@@ -929,7 +1071,9 @@ dependencies = [
|
|||||||
{ name = "pytest-playwright" },
|
{ name = "pytest-playwright" },
|
||||||
{ name = "python-dotenv" },
|
{ name = "python-dotenv" },
|
||||||
{ name = "requests" },
|
{ name = "requests" },
|
||||||
|
{ name = "sorl-thumbnail" },
|
||||||
{ name = "whitenoise" },
|
{ name = "whitenoise" },
|
||||||
|
{ name = "wiki" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
@@ -946,6 +1090,9 @@ 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-mptt", specifier = ">=0.16.0" },
|
||||||
|
{ name = "django-nyt", specifier = ">=1.4.1" },
|
||||||
{ 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" },
|
||||||
@@ -963,7 +1110,21 @@ requires-dist = [
|
|||||||
{ name = "pytest-playwright", specifier = ">=0.4.3" },
|
{ name = "pytest-playwright", specifier = ">=0.4.3" },
|
||||||
{ name = "python-dotenv", specifier = ">=1.0.1" },
|
{ name = "python-dotenv", specifier = ">=1.0.1" },
|
||||||
{ name = "requests", specifier = ">=2.32.3" },
|
{ name = "requests", specifier = ">=2.32.3" },
|
||||||
|
{ name = "sorl-thumbnail", specifier = ">=12.11.0" },
|
||||||
{ name = "whitenoise", specifier = ">=6.6.0" },
|
{ name = "whitenoise", specifier = ">=6.6.0" },
|
||||||
|
{ name = "wiki", specifier = ">=0.11.2" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tinycss2"
|
||||||
|
version = "1.4.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "webencodings" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]tinycss2-1.4.0.tar.gz", hash = "sha256:[AWS-SECRET-REMOVED]c1ae10ebccdea16fb404a9b7", size = 87085 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]tinycss2-1.4.0-py3-none-any.whl", hash = "sha256:[AWS-SECRET-REMOVED]4bb905a5775adb0d884c5289", size = 26610 },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1040,6 +1201,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]urllib3-2.3.0-py3-none-any.whl", hash = "sha256:[AWS-SECRET-REMOVED]710050facf0dd6911440e3df", size = 128369 },
|
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]urllib3-2.3.0-py3-none-any.whl", hash = "sha256:[AWS-SECRET-REMOVED]710050facf0dd6911440e3df", size = 128369 },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "webencodings"
|
||||||
|
version = "0.5.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]webencodings-0.5.1.tar.gz", hash = "sha256:[AWS-SECRET-REMOVED]865afcc4aab16748587e1923", size = 9721 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:[AWS-SECRET-REMOVED]3f95be16fc9acd2947514a78", size = 11774 },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "whitenoise"
|
name = "whitenoise"
|
||||||
version = "6.8.2"
|
version = "6.8.2"
|
||||||
@@ -1049,6 +1219,26 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]whitenoise-6.8.2-py3-none-any.whl", hash = "sha256:[AWS-SECRET-REMOVED]ab9726e5772ac50fb45d2280", size = 20158 },
|
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]whitenoise-6.8.2-py3-none-any.whl", hash = "sha256:[AWS-SECRET-REMOVED]ab9726e5772ac50fb45d2280", size = 20158 },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wiki"
|
||||||
|
version = "0.11.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "bleach", extra = ["css"] },
|
||||||
|
{ name = "django" },
|
||||||
|
{ name = "django-mptt" },
|
||||||
|
{ name = "django-nyt" },
|
||||||
|
{ name = "django-sekizai" },
|
||||||
|
{ name = "markdown" },
|
||||||
|
{ name = "pillow" },
|
||||||
|
{ name = "pymdown-extensions" },
|
||||||
|
{ name = "sorl-thumbnail" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]wiki-0.11.2.tar.gz", hash = "sha256:[AWS-SECRET-REMOVED]4140b4c9c64497736c1594d7", size = 2274191 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]wiki-0.11.2-py3-none-any.whl", hash = "sha256:[AWS-SECRET-REMOVED]a1182bd105f2cbfebbeb20aa", size = 2436316 },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zope-interface"
|
name = "zope-interface"
|
||||||
version = "7.2"
|
version = "7.2"
|
||||||
|
|||||||
1
wiki/__init__.py
Normal file
1
wiki/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
default_app_config = "wiki.apps.WikiConfig"
|
||||||
11
wiki/apps.py
Normal file
11
wiki/apps.py
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
class WikiConfig(AppConfig):
|
||||||
|
name = 'wiki'
|
||||||
|
verbose_name = 'Wiki'
|
||||||
|
|
||||||
|
def ready(self):
|
||||||
|
"""
|
||||||
|
Register signals and perform other initialization
|
||||||
|
"""
|
||||||
|
pass
|
||||||
1
wiki/plugins/parks/__init__.py
Normal file
1
wiki/plugins/parks/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
default_app_config = "wiki.plugins.parks.apps.ParksPluginConfig"
|
||||||
13
wiki/plugins/parks/apps.py
Normal file
13
wiki/plugins/parks/apps.py
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
class ParksPluginConfig(AppConfig):
|
||||||
|
name = "wiki.plugins.parks"
|
||||||
|
label = "wiki_parks"
|
||||||
|
verbose_name = "Wiki Parks Plugin"
|
||||||
|
|
||||||
|
def ready(self):
|
||||||
|
"""
|
||||||
|
Register plugin with wiki system when the app is ready.
|
||||||
|
Plugin registration is deferred until wiki core is available.
|
||||||
|
"""
|
||||||
|
pass
|
||||||
34
wiki/plugins/parks/models.py
Normal file
34
wiki/plugins/parks/models.py
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
from django.db import models
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
class ParkMetadata(models.Model):
|
||||||
|
article = models.OneToOneField(
|
||||||
|
'wiki.Article', # Using string reference to avoid import issues
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='park_metadata'
|
||||||
|
)
|
||||||
|
|
||||||
|
operator = models.CharField(
|
||||||
|
max_length=255,
|
||||||
|
verbose_name=_('Operator'),
|
||||||
|
blank=True
|
||||||
|
)
|
||||||
|
|
||||||
|
opened_date = models.DateField(
|
||||||
|
verbose_name=_('Opening Date'),
|
||||||
|
null=True,
|
||||||
|
blank=True
|
||||||
|
)
|
||||||
|
|
||||||
|
location = models.CharField(
|
||||||
|
max_length=255,
|
||||||
|
verbose_name=_('Location'),
|
||||||
|
blank=True
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = _('Park Metadata')
|
||||||
|
verbose_name_plural = _('Park Metadata')
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Park info for {self.article.current_revision.title}"
|
||||||
14
wiki/plugins/parks/wiki_plugin.py
Normal file
14
wiki/plugins/parks/wiki_plugin.py
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
from django.utils.translation import gettext as _
|
||||||
|
|
||||||
|
class ParksPlugin:
|
||||||
|
"""
|
||||||
|
Plugin for handling parks in the wiki system.
|
||||||
|
Core registration will be added later.
|
||||||
|
"""
|
||||||
|
slug = 'parks'
|
||||||
|
|
||||||
|
sidebar = {
|
||||||
|
'headline': _('Park Information'),
|
||||||
|
'icon_class': 'fa-info-circle',
|
||||||
|
'template': 'wiki/plugins/parks/sidebar.html',
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user