mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 15:31:08 -05:00
Compare commits
1 Commits
feature/dj
...
pixeebot/d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f56c4a0b37 |
30
.clinerules
30
.clinerules
@@ -1,30 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,93 +0,0 @@
|
||||
# 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",
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -55,3 +55,8 @@ class PageView(models.Model):
|
||||
return model_class.objects.filter(pk__in=id_list).order_by(preserved)
|
||||
|
||||
return model_class.objects.none()
|
||||
|
||||
def __str__(self):
|
||||
model_name = self.__class__.__name__
|
||||
fields_str = ", ".join((f"{field.name}={getattr(self, field.name)}" for field in self._meta.fields))
|
||||
return f"{model_name}({fields_str})"
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
# 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"
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -1,39 +0,0 @@
|
||||
"""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"))
|
||||
@@ -1,20 +0,0 @@
|
||||
# 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"
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -1,20 +0,0 @@
|
||||
# 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"
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -1,28 +0,0 @@
|
||||
# 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),
|
||||
),
|
||||
]
|
||||
@@ -66,6 +66,11 @@ class TrackedModel(models.Model):
|
||||
).order_by('-pgh_created_at')
|
||||
return self.__class__.objects.none()
|
||||
|
||||
def __str__(self):
|
||||
model_name = self.__class__.__name__
|
||||
fields_str = ", ".join((f"{field.name}={getattr(self, field.name)}" for field in self._meta.fields))
|
||||
return f"{model_name}({fields_str})"
|
||||
|
||||
class HistoricalSlug(models.Model):
|
||||
"""Track historical slugs for models"""
|
||||
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
# 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"
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -1,20 +0,0 @@
|
||||
# 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,133 +1,28 @@
|
||||
# Active Context - Wiki Migration & Integration
|
||||
# Active Context - Park View Modularization
|
||||
|
||||
## Current Status
|
||||
Corrected implementation strategy to use wiki-only approach instead of dual-system.
|
||||
**Objective:** Refactor parks view to use reusable card component and implement grid/list view toggle
|
||||
|
||||
### Completed Components
|
||||
1. Wiki Plugin Structure
|
||||
- Models for park metadata
|
||||
- Forms for data input
|
||||
- Templates for display
|
||||
- URL configurations
|
||||
**Current Implementation Analysis:**
|
||||
- Park cards rendered via `park_list_item.html` partial
|
||||
- Existing layout uses flex-based list structure
|
||||
- Search functionality uses HTMX for dynamic updates
|
||||
|
||||
2. Documentation
|
||||
- Technical specifications
|
||||
- Migration guide
|
||||
- Implementation decisions
|
||||
- User guide
|
||||
**Planned Changes:**
|
||||
1. **Create `park_card.html` Partial**
|
||||
- Extract card markup from `park_list_item.html`
|
||||
- Add responsive grid/list view classes
|
||||
- Include view mode toggle state
|
||||
|
||||
### Current Focus
|
||||
Migration to wiki-only system
|
||||
2. **View Toggle Implementation**
|
||||
- Add grid/list toggle UI with HTMX
|
||||
- Store view preference in cookie/localStorage
|
||||
- Update CSS for grid (grid-cols) vs list (flex) layouts
|
||||
|
||||
## Immediate Tasks
|
||||
3. **Backend Updates**
|
||||
- Add view_mode parameter to park list view
|
||||
- Modify context processor to handle layout preference
|
||||
|
||||
### 1. Data Migration
|
||||
- [x] Create migration script
|
||||
- [ ] Test migration in development
|
||||
- [ ] 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
|
||||
**Next Steps:**
|
||||
- Implement card partial with responsive classes
|
||||
- Create view toggle component
|
||||
- Update HTMX handlers to preserve view mode
|
||||
@@ -1,147 +0,0 @@
|
||||
# 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
|
||||
@@ -1,254 +0,0 @@
|
||||
# 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
|
||||
@@ -1,96 +0,0 @@
|
||||
# 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
|
||||
@@ -1,187 +0,0 @@
|
||||
# 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
|
||||
@@ -1,410 +0,0 @@
|
||||
# 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),
|
||||
]
|
||||
@@ -1,168 +0,0 @@
|
||||
# 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)
|
||||
@@ -1,287 +0,0 @@
|
||||
# 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
|
||||
@@ -1,327 +0,0 @@
|
||||
# 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',
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -1,253 +0,0 @@
|
||||
# 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
|
||||
@@ -1,306 +0,0 @@
|
||||
# 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
|
||||
@@ -1,388 +0,0 @@
|
||||
# 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'
|
||||
)
|
||||
@@ -1,339 +0,0 @@
|
||||
# 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
|
||||
@@ -1,350 +0,0 @@
|
||||
# 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
|
||||
}
|
||||
@@ -1,188 +0,0 @@
|
||||
# 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
|
||||
@@ -1,164 +0,0 @@
|
||||
# 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`
|
||||
@@ -1,180 +0,0 @@
|
||||
# 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
|
||||
@@ -1,197 +0,0 @@
|
||||
# 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
|
||||
@@ -1,63 +0,0 @@
|
||||
# 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
|
||||
@@ -1,105 +0,0 @@
|
||||
# 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
|
||||
@@ -1,119 +0,0 @@
|
||||
# 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
|
||||
@@ -1,119 +0,0 @@
|
||||
# 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')),
|
||||
]
|
||||
@@ -1,135 +0,0 @@
|
||||
# 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
|
||||
<!-- Component Structure -->
|
||||
<div class="component-wrapper">
|
||||
<div class="component-header"></div>
|
||||
<div class="component-content"></div>
|
||||
<div class="component-header"></div>
|
||||
<div class="component-content"></div>
|
||||
</div>
|
||||
```
|
||||
|
||||
@@ -161,22 +161,6 @@ class ViewTests(TestCase):
|
||||
|
||||
## 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
|
||||
1. Planning
|
||||
- Technical specification
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
# 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
|
||||
)
|
||||
from .models import Park
|
||||
from .querysets import get_base_park_queryset
|
||||
from .views import get_base_park_queryset
|
||||
from companies.models import Company
|
||||
|
||||
def validate_positive_integer(value):
|
||||
@@ -31,66 +31,44 @@ class ParkFilter(LocationFilterMixin, RatingFilterMixin, DateRangeFilterMixin, F
|
||||
model = Park
|
||||
fields = []
|
||||
|
||||
# Search field with better description
|
||||
search = CharFilter(
|
||||
method='filter_search',
|
||||
label=_("Search Parks"),
|
||||
help_text=_("Search by park name, description, or location")
|
||||
)
|
||||
# Search field
|
||||
search = CharFilter(method='filter_search')
|
||||
|
||||
# Status filter with clearer label
|
||||
# Status filter
|
||||
status = ChoiceFilter(
|
||||
field_name='status',
|
||||
choices=Park._meta.get_field('status').choices,
|
||||
empty_label=_('Any status'),
|
||||
label=_("Operating Status"),
|
||||
help_text=_("Filter parks by their current operating status")
|
||||
empty_label='Any status'
|
||||
)
|
||||
|
||||
# Owner filters with helpful descriptions
|
||||
# Owner filters
|
||||
owner = ModelChoiceFilter(
|
||||
field_name='owner',
|
||||
queryset=Company.objects.all(),
|
||||
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")
|
||||
empty_label='Any company'
|
||||
)
|
||||
has_owner = BooleanFilter(method='filter_has_owner')
|
||||
|
||||
# Ride and attraction filters
|
||||
# Numeric filters
|
||||
min_rides = NumberFilter(
|
||||
field_name='current_ride_count',
|
||||
lookup_expr='gte',
|
||||
validators=[validate_positive_integer],
|
||||
label=_("Minimum Rides"),
|
||||
help_text=_("Show parks with at least this many rides")
|
||||
validators=[validate_positive_integer]
|
||||
)
|
||||
min_coasters = NumberFilter(
|
||||
field_name='current_coaster_count',
|
||||
lookup_expr='gte',
|
||||
validators=[validate_positive_integer],
|
||||
label=_("Minimum Roller Coasters"),
|
||||
help_text=_("Show parks with at least this many roller coasters")
|
||||
validators=[validate_positive_integer]
|
||||
)
|
||||
|
||||
# Size filter
|
||||
min_size = NumberFilter(
|
||||
field_name='size_acres',
|
||||
lookup_expr='gte',
|
||||
validators=[validate_positive_integer],
|
||||
label=_("Minimum Size (acres)"),
|
||||
help_text=_("Show parks of at least this size in acres")
|
||||
validators=[validate_positive_integer]
|
||||
)
|
||||
|
||||
# Opening date filter with better label
|
||||
# Date filter
|
||||
opening_date = DateFromToRangeFilter(
|
||||
field_name='opening_date',
|
||||
label=_("Opening Date Range"),
|
||||
help_text=_("Filter parks by their opening date")
|
||||
field_name='opening_date'
|
||||
)
|
||||
|
||||
def filter_search(self, queryset, name, value):
|
||||
@@ -116,26 +94,25 @@ class ParkFilter(LocationFilterMixin, RatingFilterMixin, DateRangeFilterMixin, F
|
||||
def filter_has_owner(self, queryset, name, value):
|
||||
"""Filter parks based on whether they have an owner"""
|
||||
return queryset.filter(owner__isnull=not value)
|
||||
|
||||
@property
|
||||
def qs(self):
|
||||
"""Override qs property to ensure we always use base queryset with annotations"""
|
||||
if not hasattr(self, '_qs'):
|
||||
# Start with the base queryset that includes annotations
|
||||
base_qs = get_base_park_queryset()
|
||||
|
||||
if not self.is_bound:
|
||||
self._qs = base_qs
|
||||
return self._qs
|
||||
|
||||
if not self.form.is_valid():
|
||||
self._qs = base_qs.none()
|
||||
return self._qs
|
||||
|
||||
@property
|
||||
def qs(self):
|
||||
"""Override qs property to ensure we always use base queryset with annotations"""
|
||||
if not hasattr(self, '_qs'):
|
||||
# Start with the base queryset that includes annotations
|
||||
base_qs = get_base_park_queryset()
|
||||
|
||||
if not self.is_bound:
|
||||
self._qs = base_qs
|
||||
for name, value in self.form.cleaned_data.items():
|
||||
if value in [None, '', 0] and name not in ['has_owner']:
|
||||
continue
|
||||
self._qs = self.filters[name].filter(self._qs, value)
|
||||
self._qs = self._qs.distinct()
|
||||
return self._qs
|
||||
return self._qs
|
||||
|
||||
if not self.form.is_valid():
|
||||
self._qs = base_qs.none()
|
||||
return self._qs
|
||||
|
||||
self._qs = base_qs
|
||||
for name, value in self.form.cleaned_data.items():
|
||||
if value in [None, '', 0] and name not in ['has_owner']:
|
||||
continue
|
||||
self._qs = self.filters[name].filter(self._qs, value)
|
||||
self._qs = self._qs.distinct()
|
||||
return self._qs
|
||||
@@ -1,54 +1,7 @@
|
||||
from django import forms
|
||||
from decimal import Decimal, InvalidOperation, ROUND_DOWN
|
||||
from autocomplete import AutocompleteWidget
|
||||
|
||||
from core.forms import BaseAutocomplete
|
||||
from .models import Park
|
||||
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):
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
# 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",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -1,22 +0,0 @@
|
||||
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,8 +9,7 @@
|
||||
<div class="flex items-center space-x-4">
|
||||
<h1 class="text-2xl font-bold text-gray-900">Parks</h1>
|
||||
|
||||
<fieldset class="flex items-center space-x-2 bg-gray-100 rounded-lg p-1">
|
||||
<legend class="sr-only">View mode selection</legend>
|
||||
<div class="flex items-center space-x-2 bg-gray-100 rounded-lg p-1" role="group" aria-label="View mode selection">
|
||||
<button hx-get="{% url 'parks:park_list' %}?view_mode=grid{% if request.GET.search %}&search={{ request.GET.search }}{% endif %}"
|
||||
hx-target="#park-results"
|
||||
hx-push-url="true"
|
||||
@@ -47,25 +46,25 @@
|
||||
{% block filter_section %}
|
||||
<div class="mb-6">
|
||||
<div class="max-w-3xl mx-auto relative mb-8">
|
||||
<div class="w-full relative">
|
||||
<form hx-get="{% url 'parks:park_list' %}"
|
||||
hx-target="#park-results"
|
||||
hx-push-url="true"
|
||||
hx-trigger="change from:.park-search">
|
||||
{% csrf_token %}
|
||||
{{ search_form.park }}
|
||||
</form>
|
||||
|
||||
<!-- Loading indicator -->
|
||||
<div id="search-indicator"
|
||||
class="htmx-indicator absolute right-3 top-3"
|
||||
role="status"
|
||||
aria-label="Loading search results">
|
||||
<label for="search" class="sr-only">Search parks</label>
|
||||
<input type="search"
|
||||
name="search"
|
||||
id="search"
|
||||
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"
|
||||
placeholder="Search parks by name or location..."
|
||||
hx-get="{% url 'parks:search_parks' %}"
|
||||
hx-trigger="input delay:300ms, search"
|
||||
hx-target="#park-results"
|
||||
hx-push-url="true"
|
||||
hx-indicator="#search-indicator"
|
||||
value="{{ request.GET.search|default:'' }}"
|
||||
aria-label="Search parks">
|
||||
<div class="absolute inset-y-0 right-0 flex items-center pr-3">
|
||||
<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">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none"/>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
|
||||
</svg>
|
||||
<span class="sr-only">Searching...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -92,4 +91,8 @@
|
||||
data-view-mode="{{ view_mode|default:'grid' }}">
|
||||
{% include "parks/partials/park_list_item.html" with parks=parks view_mode=view_mode|default:'grid' %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script src="{% static 'parks/js/search.js' %}"></script>
|
||||
{% endblock %}
|
||||
@@ -11,33 +11,80 @@
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
<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 %}"
|
||||
data-testid="park-list"
|
||||
data-view-mode="{{ view_mode|default:'grid' }}">
|
||||
{% for park in object_list|default:parks %}
|
||||
<div class="overflow-hidden transition-transform transform bg-white rounded-lg shadow-lg dark:bg-gray-800 hover:-translate-y-1">
|
||||
<div class="p-4">
|
||||
<h2 class="mb-2 text-xl font-bold">
|
||||
<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>
|
||||
</h2>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span class="status-badge status-{{ park.status|lower }}">
|
||||
{{ park.get_status_display }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{% if park.owner %}
|
||||
<div class="mt-4 text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300">
|
||||
<a href="{% url 'companies:company_detail' park.owner.slug %}">
|
||||
{{ park.owner.name }}
|
||||
</a>
|
||||
<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 %}"
|
||||
data-testid="park-card"
|
||||
data-park-id="{{ park.id }}"
|
||||
data-view-mode="{{ view_mode|default:'grid' }}">
|
||||
|
||||
<a href="{% url 'parks:park_detail' park.slug %}"
|
||||
class="absolute inset-0 z-0"
|
||||
aria-label="View details for {{ park.name }}"></a>
|
||||
|
||||
<div class="relative z-10 {% if view_mode == 'grid' %}aspect-video{% endif %}">
|
||||
{% if park.photos.exists %}
|
||||
<img src="{{ park.photos.first.image.url }}"
|
||||
alt="Photo of {{ park.name }}"
|
||||
class="{% if view_mode == 'grid' %}w-full h-full object-cover rounded-t-lg{% else %}w-24 h-24 object-cover rounded-lg flex-shrink-0{% endif %}"
|
||||
loading="lazy">
|
||||
{% else %}
|
||||
<div class="{% if view_mode == 'grid' %}w-full h-full bg-gray-100 rounded-t-lg flex items-center justify-center{% else %}w-24 h-24 bg-gray-100 rounded-lg flex-shrink-0 flex items-center justify-center{% endif %}"
|
||||
role="img"
|
||||
aria-label="Park initial letter">
|
||||
<span class="text-2xl font-medium text-gray-400">{{ park.name|first|upper }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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 %}
|
||||
<div class="col-span-full p-4 text-sm text-gray-500 text-center" data-testid="no-parks-found">
|
||||
<div class="{% if view_mode == 'grid' %}col-span-full{% endif %} p-4 text-sm text-gray-500 text-center" data-testid="no-parks-found">
|
||||
{% if search_query %}
|
||||
No parks found matching "{{ search_query }}". Try adjusting your search terms.
|
||||
{% else %}
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -1,81 +0,0 @@
|
||||
# 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
|
||||
@@ -1,119 +0,0 @@
|
||||
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 . import views, views_search
|
||||
from . import views
|
||||
from rides.views import ParkSingleCategoryListView
|
||||
|
||||
app_name = "parks"
|
||||
|
||||
urlpatterns = [
|
||||
# Park views with autocomplete search
|
||||
path("", views_search.ParkSearchView.as_view(), name="park_list"),
|
||||
# Park views
|
||||
path("", views.ParkListView.as_view(), name="park_list"),
|
||||
path("create/", views.ParkCreateView.as_view(), name="park_create"),
|
||||
|
||||
# Add park button endpoint (moved before park detail pattern)
|
||||
@@ -18,8 +18,6 @@ urlpatterns = [
|
||||
|
||||
# Areas and search endpoints for HTMX
|
||||
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"),
|
||||
|
||||
# Park detail and related views
|
||||
|
||||
551
parks/views.py
551
parks/views.py
@@ -1,126 +1,61 @@
|
||||
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 typing import Any, Optional, cast, Literal
|
||||
|
||||
# Constants
|
||||
PARK_DETAIL_URL = "parks:park_detail"
|
||||
PARK_LIST_ITEM_TEMPLATE = "parks/partials/park_list_item.html"
|
||||
REQUIRED_FIELDS_ERROR = "Please correct the errors below. Required fields are marked with an asterisk (*)."
|
||||
ALLOWED_ROLES = ["MODERATOR", "ADMIN", "SUPERUSER"]
|
||||
|
||||
from django.views.generic import DetailView, ListView, CreateView, UpdateView
|
||||
from django.shortcuts import get_object_or_404, render
|
||||
from django.urls import reverse
|
||||
from django.db.models import Q, Count, QuerySet
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
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"]
|
||||
|
||||
|
||||
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:
|
||||
"""Get the current view mode from request, defaulting to grid"""
|
||||
view_mode = request.GET.get('view_mode', '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:
|
||||
"""Return the add park button partial template"""
|
||||
return render(request, "parks/partials/add_park_button.html")
|
||||
|
||||
|
||||
def park_actions(request: HttpRequest, slug: str) -> HttpResponse:
|
||||
"""Return the park actions partial template"""
|
||||
park = get_object_or_404(Park, slug=slug)
|
||||
return render(request, "parks/partials/park_actions.html", {"park": park})
|
||||
|
||||
|
||||
def get_park_areas(request: HttpRequest) -> HttpResponse:
|
||||
"""Return park areas as options for a select element"""
|
||||
park_id = request.GET.get('park')
|
||||
if not park_id:
|
||||
return HttpResponse('<option value="">Select a park first</option>')
|
||||
|
||||
|
||||
try:
|
||||
park = Park.objects.get(id=park_id)
|
||||
areas = park.areas.all()
|
||||
@@ -133,7 +68,6 @@ def get_park_areas(request: HttpRequest) -> HttpResponse:
|
||||
except Park.DoesNotExist:
|
||||
return HttpResponse('<option value="">Invalid park selected</option>')
|
||||
|
||||
|
||||
def location_search(request: HttpRequest) -> JsonResponse:
|
||||
"""Search for locations using OpenStreetMap Nominatim API"""
|
||||
query = request.GET.get("q", "")
|
||||
@@ -156,8 +90,7 @@ def location_search(request: HttpRequest) -> JsonResponse:
|
||||
|
||||
if response.status_code == 200:
|
||||
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 = [
|
||||
r for r in normalized_results
|
||||
if r["lat"] is not None and r["lon"] is not None
|
||||
@@ -166,7 +99,6 @@ def location_search(request: HttpRequest) -> JsonResponse:
|
||||
|
||||
return JsonResponse({"results": []})
|
||||
|
||||
|
||||
def reverse_geocode(request: HttpRequest) -> JsonResponse:
|
||||
"""Reverse geocode coordinates using OpenStreetMap Nominatim API"""
|
||||
try:
|
||||
@@ -222,11 +154,11 @@ class ParkListView(HTMXFilterableMixin, ListView):
|
||||
if self.request.htmx:
|
||||
return ["parks/partials/park_list_item.html"]
|
||||
return [self.template_name]
|
||||
|
||||
|
||||
def get_view_mode(self) -> ViewMode:
|
||||
"""Get the current view mode (grid or list)"""
|
||||
return get_view_mode(self.request)
|
||||
|
||||
|
||||
def get_queryset(self) -> QuerySet[Park]:
|
||||
"""Get base queryset with annotations and apply filters"""
|
||||
try:
|
||||
@@ -234,7 +166,7 @@ class ParkListView(HTMXFilterableMixin, ListView):
|
||||
except Exception as e:
|
||||
messages.error(self.request, f"Error loading parks: {str(e)}")
|
||||
queryset = self.model.objects.none()
|
||||
|
||||
|
||||
# Always initialize filterset, even if queryset failed
|
||||
self.filterset = self.get_filter_class()(self.request.GET, queryset=queryset)
|
||||
return self.filterset.qs
|
||||
@@ -248,7 +180,7 @@ class ParkListView(HTMXFilterableMixin, ListView):
|
||||
self.request.GET,
|
||||
queryset=self.model.objects.none()
|
||||
)
|
||||
|
||||
|
||||
context = super().get_context_data(**kwargs)
|
||||
context.update({
|
||||
'view_mode': self.get_view_mode(),
|
||||
@@ -280,8 +212,6 @@ def search_parks(request: HttpRequest) -> HttpResponse:
|
||||
if not search_query:
|
||||
return HttpResponse('')
|
||||
|
||||
# Get current view mode from request
|
||||
current_view_mode = request.GET.get('view_mode', 'grid')
|
||||
park_filter = ParkFilter({
|
||||
'search': search_query
|
||||
}, queryset=get_base_park_queryset())
|
||||
@@ -292,10 +222,10 @@ def search_parks(request: HttpRequest) -> HttpResponse:
|
||||
|
||||
response = render(
|
||||
request,
|
||||
PARK_LIST_ITEM_TEMPLATE,
|
||||
"parks/partials/park_list_item.html",
|
||||
{
|
||||
"parks": parks,
|
||||
"view_mode": current_view_mode,
|
||||
"view_mode": get_view_mode(request),
|
||||
"search_query": search_query,
|
||||
"is_search": True
|
||||
}
|
||||
@@ -306,7 +236,7 @@ def search_parks(request: HttpRequest) -> HttpResponse:
|
||||
except Exception as e:
|
||||
response = render(
|
||||
request,
|
||||
PARK_LIST_ITEM_TEMPLATE,
|
||||
"parks/partials/park_list_item.html",
|
||||
{
|
||||
"parks": [],
|
||||
"error": f"Error performing search: {str(e)}",
|
||||
@@ -316,7 +246,6 @@ def search_parks(request: HttpRequest) -> HttpResponse:
|
||||
response['HX-Trigger'] = 'searchError'
|
||||
return response
|
||||
|
||||
|
||||
class ParkCreateView(LoginRequiredMixin, CreateView):
|
||||
model = Park
|
||||
form_class = ParkForm
|
||||
@@ -330,8 +259,7 @@ class ParkCreateView(LoginRequiredMixin, CreateView):
|
||||
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"]
|
||||
decimal_fields = ["latitude", "longitude", "size_acres", "average_rating"]
|
||||
for field in decimal_fields:
|
||||
if data.get(field):
|
||||
data[field] = str(data[field])
|
||||
@@ -364,7 +292,324 @@ class ParkCreateView(LoginRequiredMixin, CreateView):
|
||||
|
||||
if hasattr(self.request.user, "role") and getattr(
|
||||
self.request.user, "role", None
|
||||
) in ALLOWED_ROLES:
|
||||
) in ["MODERATOR", "ADMIN", "SUPERUSER"]:
|
||||
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:
|
||||
self.object = form.save()
|
||||
submission.object_id = self.object.id
|
||||
@@ -424,7 +669,14 @@ class ParkCreateView(LoginRequiredMixin, CreateView):
|
||||
messages.success(
|
||||
self.request,
|
||||
"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 error in errors:
|
||||
@@ -432,7 +684,7 @@ class ParkCreateView(LoginRequiredMixin, CreateView):
|
||||
return super().form_invalid(form)
|
||||
|
||||
def get_success_url(self) -> str:
|
||||
return reverse(PARK_DETAIL_URL, kwargs={"slug": self.object.slug})
|
||||
return reverse("parks:park_detail", kwargs={"slug": self.object.slug})
|
||||
|
||||
|
||||
class ParkUpdateView(LoginRequiredMixin, UpdateView):
|
||||
@@ -488,7 +740,7 @@ class ParkUpdateView(LoginRequiredMixin, UpdateView):
|
||||
|
||||
if hasattr(self.request.user, "role") and getattr(
|
||||
self.request.user, "role", None
|
||||
) in ALLOWED_ROLES:
|
||||
) in ["MODERATOR", "ADMIN", "SUPERUSER"]:
|
||||
try:
|
||||
self.object = form.save()
|
||||
submission.status = "APPROVED"
|
||||
@@ -556,13 +808,13 @@ class ParkUpdateView(LoginRequiredMixin, UpdateView):
|
||||
"You will be notified when they are approved.",
|
||||
)
|
||||
return HttpResponseRedirect(
|
||||
reverse(PARK_DETAIL_URL, kwargs={"slug": self.object.slug})
|
||||
reverse("parks:park_detail", kwargs={"slug": self.object.slug})
|
||||
)
|
||||
|
||||
def form_invalid(self, form: ParkForm) -> HttpResponse:
|
||||
messages.error(
|
||||
self.request,
|
||||
REQUIRED_FIELDS_ERROR
|
||||
"Please correct the errors below. Required fields are marked with an asterisk (*).",
|
||||
)
|
||||
for field, errors in form.errors.items():
|
||||
for error in errors:
|
||||
@@ -570,62 +822,7 @@ class ParkUpdateView(LoginRequiredMixin, UpdateView):
|
||||
return super().form_invalid(form)
|
||||
|
||||
def get_success_url(self) -> str:
|
||||
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
|
||||
return reverse("parks:park_detail", kwargs={"slug": self.object.slug})
|
||||
|
||||
|
||||
class ParkAreaDetailView(
|
||||
@@ -633,7 +830,7 @@ class ParkAreaDetailView(
|
||||
EditSubmissionMixin,
|
||||
PhotoSubmissionMixin,
|
||||
HistoryMixin,
|
||||
DetailView
|
||||
DetailView,
|
||||
):
|
||||
model = ParkArea
|
||||
template_name = "parks/area_detail.html"
|
||||
@@ -657,7 +854,7 @@ class ParkAreaDetailView(
|
||||
return context
|
||||
|
||||
def get_redirect_url_pattern(self) -> str:
|
||||
return PARK_DETAIL_URL
|
||||
return "parks:park_detail"
|
||||
|
||||
def get_redirect_url_kwargs(self) -> dict[str, str]:
|
||||
area = cast(ParkArea, self.object)
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
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,9 +57,4 @@ dependencies = [
|
||||
"playwright>=1.41.0",
|
||||
"pytest-playwright>=0.4.3",
|
||||
"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",
|
||||
]
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
# 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"
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -1,76 +0,0 @@
|
||||
# Generated by Django 5.1.4 on 2025-02-21 17:55
|
||||
|
||||
import pgtrigger.migrations
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("parks", "0003_alter_park_id_alter_parkarea_id_and_more"),
|
||||
("rides", "0005_fix_event_context_fields"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name="rideevent",
|
||||
options={"managed": False},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name="ridemodelevent",
|
||||
options={"managed": False},
|
||||
),
|
||||
pgtrigger.migrations.RemoveTrigger(
|
||||
model_name="ride",
|
||||
name="insert_insert",
|
||||
),
|
||||
pgtrigger.migrations.RemoveTrigger(
|
||||
model_name="ride",
|
||||
name="update_update",
|
||||
),
|
||||
pgtrigger.migrations.RemoveTrigger(
|
||||
model_name="ridemodel",
|
||||
name="insert_insert",
|
||||
),
|
||||
pgtrigger.migrations.RemoveTrigger(
|
||||
model_name="ridemodel",
|
||||
name="update_update",
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="ride",
|
||||
name="id",
|
||||
field=models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="ride",
|
||||
name="status",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("", "Select status"),
|
||||
("OPERATING", "Operating"),
|
||||
("CLOSED_TEMP", "Temporarily Closed"),
|
||||
("SBNO", "Standing But Not Operating"),
|
||||
("CLOSING", "Closing"),
|
||||
("CLOSED_PERM", "Permanently Closed"),
|
||||
("UNDER_CONSTRUCTION", "Under Construction"),
|
||||
("DEMOLISHED", "Demolished"),
|
||||
("RELOCATED", "Relocated"),
|
||||
],
|
||||
default="OPERATING",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="ridemodel",
|
||||
name="id",
|
||||
field=models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
|
||||
),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name="ride",
|
||||
unique_together={("park", "slug")},
|
||||
),
|
||||
]
|
||||
@@ -1,21 +0,0 @@
|
||||
# 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,21 +1,16 @@
|
||||
{% load static %}
|
||||
{% load filter_utils %}
|
||||
|
||||
<div class="filter-container" x-data="{ open: false }">
|
||||
<div class="filter-container bg-white rounded-lg shadow p-4" x-data="{ open: false }">
|
||||
{# Mobile Filter Toggle #}
|
||||
<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">
|
||||
<span class="font-medium text-gray-900">
|
||||
<span class="mr-2">
|
||||
<svg class="w-5 h-5 inline-block" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<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>
|
||||
</span>
|
||||
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>
|
||||
<div class="lg:hidden">
|
||||
<button @click="open = !open" type="button" class="w-full flex items-center justify-between p-2 text-gray-400 hover:text-gray-500">
|
||||
<span class="font-medium text-gray-900">Filters</span>
|
||||
<span class="ml-6 flex items-center">
|
||||
<svg class="w-5 h-5" x-show="!open" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M10 6L16 12H4L10 6Z"/>
|
||||
</svg>
|
||||
<svg class="w-5 h-5" x-show="open" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M10 14L4 8H16L10 14Z"/>
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
@@ -23,23 +18,20 @@
|
||||
|
||||
{# Filter Form #}
|
||||
<form hx-get="{{ request.path }}"
|
||||
hx-trigger="change delay:500ms"
|
||||
hx-trigger="change delay:500ms, submit"
|
||||
hx-target="#results-container"
|
||||
hx-push-url="true"
|
||||
class="space-y-6"
|
||||
class="mt-4 lg:mt-0"
|
||||
x-show="open || $screen('lg')"
|
||||
x-transition>
|
||||
|
||||
{# Active Filters Summary #}
|
||||
{% if applied_filters %}
|
||||
<div class="bg-blue-50 rounded-lg p-4 shadow-sm border border-blue-100">
|
||||
<div class="bg-blue-50 p-4 rounded-lg mb-4">
|
||||
<div class="flex justify-between items-center">
|
||||
<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>
|
||||
<h3 class="text-sm font-medium text-blue-800">Active Filters</h3>
|
||||
<a href="{{ request.path }}"
|
||||
class="text-sm font-medium text-blue-600 hover:text-blue-500 hover:underline"
|
||||
class="text-sm text-blue-600 hover:text-blue-500"
|
||||
hx-get="{{ request.path }}"
|
||||
hx-target="#results-container"
|
||||
hx-push-url="true">
|
||||
@@ -50,35 +42,21 @@
|
||||
{% endif %}
|
||||
|
||||
{# Filter Groups #}
|
||||
<div class="bg-white rounded-lg shadow divide-y divide-gray-200">
|
||||
<div class="space-y-4">
|
||||
{% for fieldset in filter.form|groupby_filters %}
|
||||
<div class="p-6" x-data="{ expanded: true }">
|
||||
{# Group Header #}
|
||||
<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>
|
||||
<div class="border-b border-gray-200 pb-4">
|
||||
<h3 class="text-sm font-medium text-gray-900 mb-3">{{ fieldset.name }}</h3>
|
||||
<div class="space-y-3">
|
||||
{% for field in fieldset.fields %}
|
||||
<div class="filter-field">
|
||||
<label for="{{ field.id_for_label }}"
|
||||
class="block text-sm font-medium text-gray-700 mb-1">
|
||||
<div>
|
||||
<label for="{{ field.id_for_label }}" class="text-sm text-gray-600">
|
||||
{{ field.label }}
|
||||
</label>
|
||||
<div class="mt-1">
|
||||
{{ field|add_field_classes }}
|
||||
{{ field }}
|
||||
</div>
|
||||
{% if field.help_text %}
|
||||
<p class="mt-1 text-sm text-gray-500">{{ field.help_text }}</p>
|
||||
<p class="mt-1 text-xs text-gray-500">{{ field.help_text }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
@@ -87,25 +65,17 @@
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{# Mobile Apply Button #}
|
||||
<div class="lg:hidden">
|
||||
{# Submit Button - Only visible on mobile #}
|
||||
<div class="mt-4 lg:hidden">
|
||||
<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 focus:ring-offset-2 transition duration-150 ease-in-out">
|
||||
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">
|
||||
Apply Filters
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{# Required Scripts #}
|
||||
{% block extra_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>
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data('filterForm', () => ({
|
||||
expanded: true,
|
||||
toggle() {
|
||||
this.expanded = !this.expanded
|
||||
}
|
||||
}))
|
||||
})
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -32,18 +32,17 @@ def groupby_filters(form: Form) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
groups = []
|
||||
|
||||
# Define groups and their patterns with specific ordering
|
||||
# Define groups and their patterns
|
||||
group_patterns = {
|
||||
'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,
|
||||
'Search': lambda f: f.name in ['search', 'q'],
|
||||
'Location': lambda f: f.name.startswith('location') or 'address' in f.name,
|
||||
'Ratings': lambda f: 'rating' in f.name,
|
||||
'Opening Info': lambda f: 'opening' in f.name or 'date' in f.name,
|
||||
'Dates': lambda f: any(x in f.name for x in ['date', 'created', 'updated']),
|
||||
'Rating': lambda f: 'rating' 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 with ordering preserved
|
||||
# Initialize group containers
|
||||
grouped_fields: Dict[str, List] = {name: [] for name in group_patterns.keys()}
|
||||
ungrouped = []
|
||||
|
||||
@@ -58,7 +57,7 @@ def groupby_filters(form: Form) -> List[Dict[str, Any]]:
|
||||
if not grouped:
|
||||
ungrouped.append(field)
|
||||
|
||||
# Build final groups list, maintaining order and only including non-empty groups
|
||||
# Build final groups list, only including non-empty groups
|
||||
for name, fields in grouped_fields.items():
|
||||
if fields:
|
||||
groups.append({
|
||||
@@ -69,7 +68,7 @@ def groupby_filters(form: Form) -> List[Dict[str, Any]]:
|
||||
# Add ungrouped fields at the end if any exist
|
||||
if ungrouped:
|
||||
groups.append({
|
||||
'name': 'Other Filters',
|
||||
'name': 'Other',
|
||||
'fields': ungrouped
|
||||
})
|
||||
|
||||
@@ -87,26 +86,15 @@ def add_field_classes(field: Any) -> Any:
|
||||
"""
|
||||
Add appropriate Tailwind classes based on field type
|
||||
"""
|
||||
base_classes = "transition duration-150 ease-in-out "
|
||||
|
||||
classes = {
|
||||
'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': base_classes + 'h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-2 focus:ring-blue-500',
|
||||
'radio': base_classes + 'h-4 w-4 border-gray-300 text-blue-600 focus:ring-2 focus:ring-blue-500',
|
||||
'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': 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',
|
||||
'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',
|
||||
'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',
|
||||
'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',
|
||||
'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',
|
||||
'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',
|
||||
}
|
||||
|
||||
field_type = get_field_type(field)
|
||||
css_class = classes.get(field_type, classes['default'])
|
||||
|
||||
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)
|
||||
return field.as_widget(attrs={'class': css_class})
|
||||
@@ -2325,11 +2325,6 @@ select {
|
||||
margin-bottom: auto;
|
||||
}
|
||||
|
||||
.-mx-4 {
|
||||
margin-left: -1rem;
|
||||
margin-right: -1rem;
|
||||
}
|
||||
|
||||
.-mb-px {
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
@@ -2446,10 +2441,6 @@ select {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.mt-8 {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.block {
|
||||
display: block;
|
||||
}
|
||||
@@ -2530,10 +2521,6 @@ select {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.h-64 {
|
||||
height: 16rem;
|
||||
}
|
||||
|
||||
.max-h-60 {
|
||||
max-height: 15rem;
|
||||
}
|
||||
@@ -2591,18 +2578,10 @@ select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.w-48 {
|
||||
width: 12rem;
|
||||
}
|
||||
|
||||
.min-w-\[200px\] {
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.min-w-full {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.max-w-2xl {
|
||||
max-width: 42rem;
|
||||
}
|
||||
@@ -2643,10 +2622,6 @@ select {
|
||||
max-width: 20rem;
|
||||
}
|
||||
|
||||
.max-w-full {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.flex-1 {
|
||||
flex: 1 1 0%;
|
||||
}
|
||||
@@ -2723,14 +2698,6 @@ select {
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.list-decimal {
|
||||
list-style-type: decimal;
|
||||
}
|
||||
|
||||
.list-disc {
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
.grid-cols-1 {
|
||||
grid-template-columns: repeat(1, minmax(0, 1fr));
|
||||
}
|
||||
@@ -2857,17 +2824,6 @@ select {
|
||||
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;
|
||||
}
|
||||
@@ -2876,18 +2832,10 @@ select {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.overflow-x-auto {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.overflow-y-auto {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.whitespace-nowrap {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rounded {
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
@@ -2958,10 +2906,6 @@ select {
|
||||
border-top-width: 1px;
|
||||
}
|
||||
|
||||
.border-l-4 {
|
||||
border-left-width: 4px;
|
||||
}
|
||||
|
||||
.border-dashed {
|
||||
border-style: dashed;
|
||||
}
|
||||
@@ -3334,14 +3278,6 @@ select {
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
|
||||
.pl-4 {
|
||||
padding-left: 1rem;
|
||||
}
|
||||
|
||||
.pt-4 {
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.text-left {
|
||||
text-align: left;
|
||||
}
|
||||
@@ -3414,18 +3350,10 @@ select {
|
||||
text-transform: lowercase;
|
||||
}
|
||||
|
||||
.italic {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.leading-tight {
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.tracking-wider {
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.text-blue-400 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(96 165 250 / var(--tw-text-opacity));
|
||||
@@ -3909,21 +3837,6 @@ select {
|
||||
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 {
|
||||
text-decoration-line: underline;
|
||||
}
|
||||
@@ -4549,10 +4462,6 @@ select {
|
||||
grid-column: span 2 / span 2;
|
||||
}
|
||||
|
||||
.lg\:mb-0 {
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
|
||||
.lg\:flex {
|
||||
display: flex;
|
||||
}
|
||||
@@ -4561,14 +4470,6 @@ select {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.lg\:w-1\/4 {
|
||||
width: 25%;
|
||||
}
|
||||
|
||||
.lg\:w-3\/4 {
|
||||
width: 75%;
|
||||
}
|
||||
|
||||
.lg\:grid-cols-3 {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
{% 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,13 +223,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
data-result-index="${index}">
|
||||
<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">
|
||||
${[
|
||||
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(', ')}
|
||||
${(result.address && result.address.city) ? result.address.city + ', ' : ''}${(result.address && result.address.country) || ''}
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
@@ -319,12 +313,12 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
const address = {
|
||||
name: result.display_name || result.name || '',
|
||||
address: {
|
||||
house_number: result.house_number || (result.address && result.address.house_number) || '',
|
||||
road: result.street || (result.address && (result.address.road || result.address.street)) || '',
|
||||
city: result.city || (result.address && (result.address.city || result.address.town || result.address.village)) || '',
|
||||
state: result.state || (result.address && (result.address.state || result.address.region)) || '',
|
||||
country: result.country || (result.address && result.address.country) || '',
|
||||
postcode: result.postal_code || (result.address && result.address.postcode) || ''
|
||||
house_number: result.address ? result.address.house_number : '',
|
||||
road: result.address ? (result.address.road || result.address.street) : '',
|
||||
city: result.address ? (result.address.city || result.address.town || result.address.village) : '',
|
||||
state: result.address ? (result.address.state || result.address.region) : '',
|
||||
country: result.address ? result.address.country : '',
|
||||
postcode: result.address ? result.address.postcode : ''
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -1,106 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -1,84 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -1,200 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -1,146 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -1,146 +0,0 @@
|
||||
{% 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>
|
||||
@@ -1,126 +0,0 @@
|
||||
{% 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,120 +1,223 @@
|
||||
from django.conf import settings as django_settings
|
||||
import os
|
||||
"""
|
||||
Django settings for thrillwiki project.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
import os
|
||||
|
||||
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
SECRET_KEY = "django-insecure-=0)^0#h#k$0@$8$ys=^$0#h#k$0@$8$ys=^"
|
||||
|
||||
# Quick-start development settings - unsuitable for production
|
||||
SECRET_KEY = 'django-insecure-key-for-development'
|
||||
# SECURITY WARNING: don't run with debug turned on in production!
|
||||
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
|
||||
INSTALLED_APPS = [
|
||||
'django.contrib.admin',
|
||||
'django.contrib.auth',
|
||||
'django.contrib.contenttypes',
|
||||
'django.contrib.sessions',
|
||||
'django.contrib.messages',
|
||||
'django.contrib.staticfiles',
|
||||
'django.contrib.sites.apps.SitesConfig',
|
||||
'django.contrib.humanize.apps.HumanizeConfig',
|
||||
'django_nyt.apps.DjangoNytConfig',
|
||||
'mptt',
|
||||
'sorl.thumbnail',
|
||||
'wiki.apps.WikiConfig', # Main wiki app
|
||||
'wiki.plugins.parks.apps.ParksPluginConfig', # Parks plugin
|
||||
"django.contrib.admin",
|
||||
"django.contrib.auth",
|
||||
"django.contrib.contenttypes",
|
||||
"django.contrib.sessions",
|
||||
"django.contrib.messages",
|
||||
"django.contrib.staticfiles",
|
||||
"django.contrib.sites",
|
||||
"django.contrib.gis", # Add GeoDjango
|
||||
"pghistory", # Add django-pghistory
|
||||
"pgtrigger", # Required by django-pghistory
|
||||
"history.apps.HistoryConfig", # History timeline app
|
||||
"allauth",
|
||||
"allauth.account",
|
||||
"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 = [
|
||||
'django.middleware.security.SecurityMiddleware',
|
||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
'django.middleware.csrf.CsrfViewMiddleware',
|
||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||
'django.contrib.messages.middleware.MessageMiddleware',
|
||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||
"django.middleware.cache.UpdateCacheMiddleware",
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
"whitenoise.middleware.WhiteNoiseMiddleware",
|
||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||
"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 = [
|
||||
{
|
||||
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||
'DIRS': [BASE_DIR / 'templates'],
|
||||
'APP_DIRS': True,
|
||||
'OPTIONS': {
|
||||
'context_processors': [
|
||||
'django.template.context_processors.debug',
|
||||
'django.template.context_processors.request',
|
||||
'django.contrib.auth.context_processors.auth',
|
||||
'django.contrib.messages.context_processors.messages',
|
||||
'django.template.context_processors.media',
|
||||
'django.template.context_processors.static',
|
||||
'django.template.context_processors.tz',
|
||||
],
|
||||
},
|
||||
},
|
||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||
"DIRS": [os.path.join(BASE_DIR, "templates")],
|
||||
"APP_DIRS": True,
|
||||
"OPTIONS": {
|
||||
"context_processors": [
|
||||
"django.template.context_processors.debug",
|
||||
"django.template.context_processors.request",
|
||||
"django.contrib.auth.context_processors.auth",
|
||||
"django.contrib.messages.context_processors.messages",
|
||||
"moderation.context_processors.moderation_access",
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
WSGI_APPLICATION = 'thrillwiki.wsgi.application'
|
||||
WSGI_APPLICATION = "thrillwiki.wsgi.application"
|
||||
|
||||
# Database
|
||||
DATABASES = {
|
||||
'default': {
|
||||
'ENGINE': 'django.db.backends.postgresql',
|
||||
'NAME': 'thrillwiki',
|
||||
'USER': 'postgres',
|
||||
'PASSWORD': 'postgres',
|
||||
'HOST': 'localhost',
|
||||
'PORT': '5432',
|
||||
"default": {
|
||||
"ENGINE": "django.contrib.gis.db.backends.postgis", # Update to use PostGIS
|
||||
"NAME": "thrillwiki",
|
||||
"USER": "wiki",
|
||||
"PASSWORD": "thrillwiki",
|
||||
"HOST": "192.168.86.3",
|
||||
"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 = [
|
||||
{
|
||||
'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
|
||||
LANGUAGE_CODE = 'en-us'
|
||||
TIME_ZONE = 'UTC'
|
||||
LANGUAGE_CODE = "en-us"
|
||||
TIME_ZONE = "America/New_York"
|
||||
USE_I18N = True
|
||||
USE_TZ = True
|
||||
|
||||
# Static files (CSS, JavaScript, Images)
|
||||
STATIC_URL = 'static/'
|
||||
STATIC_ROOT = BASE_DIR / 'staticfiles'
|
||||
STATICFILES_DIRS = [
|
||||
BASE_DIR / 'static',
|
||||
]
|
||||
# Static files (CSS JavaScript Images)
|
||||
STATIC_URL = "static/"
|
||||
STATICFILES_DIRS = [BASE_DIR / "static"]
|
||||
STATIC_ROOT = os.path.join(BASE_DIR, "staticfiles")
|
||||
|
||||
# Media files
|
||||
MEDIA_URL = '/media/'
|
||||
MEDIA_ROOT = BASE_DIR / 'uploads'
|
||||
MEDIA_URL = "/media/"
|
||||
MEDIA_ROOT = os.path.join(BASE_DIR, "media")
|
||||
|
||||
# Default primary key field type
|
||||
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
||||
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||
|
||||
# Authentication settings
|
||||
AUTHENTICATION_BACKENDS = [
|
||||
"django.contrib.auth.backends.ModelBackend",
|
||||
"allauth.account.auth_backends.AuthenticationBackend",
|
||||
]
|
||||
|
||||
# django-allauth settings
|
||||
SITE_ID = 1
|
||||
ACCOUNT_EMAIL_REQUIRED = True
|
||||
ACCOUNT_USERNAME_REQUIRED = True
|
||||
ACCOUNT_LOGIN_METHODS = {'email', 'username'}
|
||||
ACCOUNT_EMAIL_VERIFICATION = "optional"
|
||||
LOGIN_REDIRECT_URL = "/"
|
||||
ACCOUNT_LOGOUT_REDIRECT_URL = "/"
|
||||
|
||||
# Custom adapters
|
||||
ACCOUNT_ADAPTER = "accounts.adapters.CustomAccountAdapter"
|
||||
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_BACKEND = 'django.core.mail.backends.console.EmailBackend'
|
||||
EMAIL_BACKEND = "email_service.backends.ForwardEmailBackend"
|
||||
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,6 +1,82 @@
|
||||
from django.contrib import admin
|
||||
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 = [
|
||||
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,5 +1,4 @@
|
||||
version = 1
|
||||
revision = 1
|
||||
requires-python = ">=3.13"
|
||||
|
||||
[[package]]
|
||||
@@ -64,23 +63,6 @@ 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 },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "certifi"
|
||||
version = "2024.12.14"
|
||||
@@ -284,18 +266,6 @@ 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 }
|
||||
|
||||
[[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]]
|
||||
name = "django-cleanup"
|
||||
version = "9.0.0"
|
||||
@@ -343,54 +313,6 @@ 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 },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "django-oauth-toolkit"
|
||||
version = "3.0.1"
|
||||
@@ -431,19 +353,6 @@ 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 },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "django-simple-history"
|
||||
version = "3.7.0"
|
||||
@@ -599,15 +508,6 @@ 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 },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "mccabe"
|
||||
version = "0.7.0"
|
||||
@@ -832,19 +732,6 @@ 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 },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "pyopenssl"
|
||||
version = "24.3.0"
|
||||
@@ -933,23 +820,6 @@ 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 },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "redis"
|
||||
version = "5.2.1"
|
||||
@@ -1007,15 +877,6 @@ 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 },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "sqlparse"
|
||||
version = "0.5.3"
|
||||
@@ -1051,9 +912,6 @@ dependencies = [
|
||||
{ name = "django-cors-headers" },
|
||||
{ name = "django-filter" },
|
||||
{ name = "django-htmx" },
|
||||
{ name = "django-htmx-autocomplete" },
|
||||
{ name = "django-mptt" },
|
||||
{ name = "django-nyt" },
|
||||
{ name = "django-oauth-toolkit" },
|
||||
{ name = "django-pghistory" },
|
||||
{ name = "django-simple-history" },
|
||||
@@ -1071,9 +929,7 @@ dependencies = [
|
||||
{ name = "pytest-playwright" },
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "requests" },
|
||||
{ name = "sorl-thumbnail" },
|
||||
{ name = "whitenoise" },
|
||||
{ name = "wiki" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
@@ -1090,9 +946,6 @@ requires-dist = [
|
||||
{ name = "django-cors-headers", specifier = ">=4.3.1" },
|
||||
{ name = "django-filter", specifier = ">=23.5" },
|
||||
{ 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-pghistory", specifier = ">=3.5.2" },
|
||||
{ name = "django-simple-history", specifier = ">=3.5.0" },
|
||||
@@ -1110,21 +963,7 @@ requires-dist = [
|
||||
{ name = "pytest-playwright", specifier = ">=0.4.3" },
|
||||
{ name = "python-dotenv", specifier = ">=1.0.1" },
|
||||
{ name = "requests", specifier = ">=2.32.3" },
|
||||
{ name = "sorl-thumbnail", specifier = ">=12.11.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]]
|
||||
@@ -1201,15 +1040,6 @@ 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 },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "whitenoise"
|
||||
version = "6.8.2"
|
||||
@@ -1219,26 +1049,6 @@ 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 },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "zope-interface"
|
||||
version = "7.2"
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
default_app_config = "wiki.apps.WikiConfig"
|
||||
11
wiki/apps.py
11
wiki/apps.py
@@ -1,11 +0,0 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
class WikiConfig(AppConfig):
|
||||
name = 'wiki'
|
||||
verbose_name = 'Wiki'
|
||||
|
||||
def ready(self):
|
||||
"""
|
||||
Register signals and perform other initialization
|
||||
"""
|
||||
pass
|
||||
@@ -1 +0,0 @@
|
||||
default_app_config = "wiki.plugins.parks.apps.ParksPluginConfig"
|
||||
@@ -1,13 +0,0 @@
|
||||
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
|
||||
@@ -1,34 +0,0 @@
|
||||
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}"
|
||||
@@ -1,14 +0,0 @@
|
||||
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