mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-30 08:27:00 -05:00
Compare commits
1 Commits
feature/dj
...
f56c4a0b37
| 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.filter(pk__in=id_list).order_by(preserved)
|
||||||
|
|
||||||
return model_class.objects.none()
|
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')
|
).order_by('-pgh_created_at')
|
||||||
return self.__class__.objects.none()
|
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):
|
class HistoricalSlug(models.Model):
|
||||||
"""Track historical slugs for models"""
|
"""Track historical slugs for models"""
|
||||||
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
|
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
|
**Objective:** Refactor parks view to use reusable card component and implement grid/list view toggle
|
||||||
Corrected implementation strategy to use wiki-only approach instead of dual-system.
|
|
||||||
|
|
||||||
### Completed Components
|
**Current Implementation Analysis:**
|
||||||
1. Wiki Plugin Structure
|
- Park cards rendered via `park_list_item.html` partial
|
||||||
- Models for park metadata
|
- Existing layout uses flex-based list structure
|
||||||
- Forms for data input
|
- Search functionality uses HTMX for dynamic updates
|
||||||
- Templates for display
|
|
||||||
- URL configurations
|
|
||||||
|
|
||||||
2. Documentation
|
**Planned Changes:**
|
||||||
- Technical specifications
|
1. **Create `park_card.html` Partial**
|
||||||
- Migration guide
|
- Extract card markup from `park_list_item.html`
|
||||||
- Implementation decisions
|
- Add responsive grid/list view classes
|
||||||
- User guide
|
- Include view mode toggle state
|
||||||
|
|
||||||
### Current Focus
|
2. **View Toggle Implementation**
|
||||||
Migration to wiki-only system
|
- 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
|
**Next Steps:**
|
||||||
- [x] Create migration script
|
- Implement card partial with responsive classes
|
||||||
- [ ] Test migration in development
|
- Create view toggle component
|
||||||
- [ ] Backup production data
|
- Update HTMX handlers to preserve view mode
|
||||||
- [ ] 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
|
|
||||||
@@ -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
|
|
||||||
@@ -161,22 +161,6 @@ class ViewTests(TestCase):
|
|||||||
|
|
||||||
## Development Workflows
|
## Development Workflows
|
||||||
|
|
||||||
### Package Management
|
|
||||||
IMPORTANT: When adding Python packages to the project, only use UV:
|
|
||||||
```bash
|
|
||||||
uv add <package>
|
|
||||||
```
|
|
||||||
Do not attempt to install packages using any other method (pip, poetry, etc.).
|
|
||||||
|
|
||||||
### Development Server Management
|
|
||||||
Server Startup Process
|
|
||||||
IMPORTANT: Always execute the following command exactly as shown to start the development server:
|
|
||||||
```bash
|
|
||||||
lsof -ti :8000 | xargs kill -9; find . -type d -name "__pycache__" -exec rm -r {} +; uv run manage.py tailwind runserver
|
|
||||||
```
|
|
||||||
|
|
||||||
Note: These steps must be executed in this exact order as a single command to ensure consistent behavior.
|
|
||||||
|
|
||||||
### Feature Development
|
### Feature Development
|
||||||
1. Planning
|
1. Planning
|
||||||
- Technical specification
|
- Technical specification
|
||||||
|
|||||||
@@ -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
|
BooleanFilter
|
||||||
)
|
)
|
||||||
from .models import Park
|
from .models import Park
|
||||||
from .querysets import get_base_park_queryset
|
from .views import get_base_park_queryset
|
||||||
from companies.models import Company
|
from companies.models import Company
|
||||||
|
|
||||||
def validate_positive_integer(value):
|
def validate_positive_integer(value):
|
||||||
@@ -31,66 +31,44 @@ class ParkFilter(LocationFilterMixin, RatingFilterMixin, DateRangeFilterMixin, F
|
|||||||
model = Park
|
model = Park
|
||||||
fields = []
|
fields = []
|
||||||
|
|
||||||
# Search field with better description
|
# Search field
|
||||||
search = CharFilter(
|
search = CharFilter(method='filter_search')
|
||||||
method='filter_search',
|
|
||||||
label=_("Search Parks"),
|
|
||||||
help_text=_("Search by park name, description, or location")
|
|
||||||
)
|
|
||||||
|
|
||||||
# Status filter with clearer label
|
# Status filter
|
||||||
status = ChoiceFilter(
|
status = ChoiceFilter(
|
||||||
field_name='status',
|
field_name='status',
|
||||||
choices=Park._meta.get_field('status').choices,
|
choices=Park._meta.get_field('status').choices,
|
||||||
empty_label=_('Any status'),
|
empty_label='Any status'
|
||||||
label=_("Operating Status"),
|
|
||||||
help_text=_("Filter parks by their current operating status")
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Owner filters with helpful descriptions
|
# Owner filters
|
||||||
owner = ModelChoiceFilter(
|
owner = ModelChoiceFilter(
|
||||||
field_name='owner',
|
field_name='owner',
|
||||||
queryset=Company.objects.all(),
|
queryset=Company.objects.all(),
|
||||||
empty_label=_('Any company'),
|
empty_label='Any company'
|
||||||
label=_("Operating Company"),
|
|
||||||
help_text=_("Filter parks by their operating company")
|
|
||||||
)
|
|
||||||
has_owner = BooleanFilter(
|
|
||||||
method='filter_has_owner',
|
|
||||||
label=_("Company Status"),
|
|
||||||
help_text=_("Show parks with or without an operating company")
|
|
||||||
)
|
)
|
||||||
|
has_owner = BooleanFilter(method='filter_has_owner')
|
||||||
|
|
||||||
# Ride and attraction filters
|
# Numeric filters
|
||||||
min_rides = NumberFilter(
|
min_rides = NumberFilter(
|
||||||
field_name='current_ride_count',
|
field_name='current_ride_count',
|
||||||
lookup_expr='gte',
|
lookup_expr='gte',
|
||||||
validators=[validate_positive_integer],
|
validators=[validate_positive_integer]
|
||||||
label=_("Minimum Rides"),
|
|
||||||
help_text=_("Show parks with at least this many rides")
|
|
||||||
)
|
)
|
||||||
min_coasters = NumberFilter(
|
min_coasters = NumberFilter(
|
||||||
field_name='current_coaster_count',
|
field_name='current_coaster_count',
|
||||||
lookup_expr='gte',
|
lookup_expr='gte',
|
||||||
validators=[validate_positive_integer],
|
validators=[validate_positive_integer]
|
||||||
label=_("Minimum Roller Coasters"),
|
|
||||||
help_text=_("Show parks with at least this many roller coasters")
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Size filter
|
|
||||||
min_size = NumberFilter(
|
min_size = NumberFilter(
|
||||||
field_name='size_acres',
|
field_name='size_acres',
|
||||||
lookup_expr='gte',
|
lookup_expr='gte',
|
||||||
validators=[validate_positive_integer],
|
validators=[validate_positive_integer]
|
||||||
label=_("Minimum Size (acres)"),
|
|
||||||
help_text=_("Show parks of at least this size in acres")
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Opening date filter with better label
|
# Date filter
|
||||||
opening_date = DateFromToRangeFilter(
|
opening_date = DateFromToRangeFilter(
|
||||||
field_name='opening_date',
|
field_name='opening_date'
|
||||||
label=_("Opening Date Range"),
|
|
||||||
help_text=_("Filter parks by their opening date")
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def filter_search(self, queryset, name, value):
|
def filter_search(self, queryset, name, value):
|
||||||
@@ -116,7 +94,6 @@ class ParkFilter(LocationFilterMixin, RatingFilterMixin, DateRangeFilterMixin, F
|
|||||||
def filter_has_owner(self, queryset, name, value):
|
def filter_has_owner(self, queryset, name, value):
|
||||||
"""Filter parks based on whether they have an owner"""
|
"""Filter parks based on whether they have an owner"""
|
||||||
return queryset.filter(owner__isnull=not value)
|
return queryset.filter(owner__isnull=not value)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def qs(self):
|
def qs(self):
|
||||||
"""Override qs property to ensure we always use base queryset with annotations"""
|
"""Override qs property to ensure we always use base queryset with annotations"""
|
||||||
|
|||||||
@@ -1,54 +1,7 @@
|
|||||||
from django import forms
|
from django import forms
|
||||||
from decimal import Decimal, InvalidOperation, ROUND_DOWN
|
from decimal import Decimal, InvalidOperation, ROUND_DOWN
|
||||||
from autocomplete import AutocompleteWidget
|
|
||||||
|
|
||||||
from core.forms import BaseAutocomplete
|
|
||||||
from .models import Park
|
from .models import Park
|
||||||
from location.models import Location
|
from location.models import Location
|
||||||
from .querysets import get_base_park_queryset
|
|
||||||
|
|
||||||
|
|
||||||
class ParkAutocomplete(BaseAutocomplete):
|
|
||||||
"""Autocomplete for searching parks.
|
|
||||||
|
|
||||||
Features:
|
|
||||||
- Name-based search with partial matching
|
|
||||||
- Prefetches related owner data
|
|
||||||
- Applies standard park queryset filtering
|
|
||||||
- Includes park status and location in results
|
|
||||||
"""
|
|
||||||
model = Park
|
|
||||||
search_attrs = ['name'] # We'll match on park names
|
|
||||||
|
|
||||||
def get_search_results(self, search):
|
|
||||||
"""Return search results with related data."""
|
|
||||||
return (get_base_park_queryset()
|
|
||||||
.filter(name__icontains=search)
|
|
||||||
.select_related('owner')
|
|
||||||
.order_by('name'))
|
|
||||||
|
|
||||||
def format_result(self, park):
|
|
||||||
"""Format each park result with status and location."""
|
|
||||||
location = park.formatted_location
|
|
||||||
location_text = f" • {location}" if location else ""
|
|
||||||
return {
|
|
||||||
'key': str(park.pk),
|
|
||||||
'label': park.name,
|
|
||||||
'extra': f"{park.get_status_display()}{location_text}"
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class ParkSearchForm(forms.Form):
|
|
||||||
"""Form for searching parks with autocomplete."""
|
|
||||||
park = forms.ModelChoiceField(
|
|
||||||
queryset=Park.objects.all(),
|
|
||||||
required=False,
|
|
||||||
widget=AutocompleteWidget(
|
|
||||||
ac_class=ParkAutocomplete,
|
|
||||||
attrs={'class': 'w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white',
|
|
||||||
'placeholder': 'Search parks...'}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ParkForm(forms.ModelForm):
|
class ParkForm(forms.ModelForm):
|
||||||
|
|||||||
@@ -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">
|
<div class="flex items-center space-x-4">
|
||||||
<h1 class="text-2xl font-bold text-gray-900">Parks</h1>
|
<h1 class="text-2xl font-bold text-gray-900">Parks</h1>
|
||||||
|
|
||||||
<fieldset class="flex items-center space-x-2 bg-gray-100 rounded-lg p-1">
|
<div class="flex items-center space-x-2 bg-gray-100 rounded-lg p-1" role="group" aria-label="View mode selection">
|
||||||
<legend class="sr-only">View mode selection</legend>
|
|
||||||
<button hx-get="{% url 'parks:park_list' %}?view_mode=grid{% if request.GET.search %}&search={{ request.GET.search }}{% endif %}"
|
<button hx-get="{% url 'parks:park_list' %}?view_mode=grid{% if request.GET.search %}&search={{ request.GET.search }}{% endif %}"
|
||||||
hx-target="#park-results"
|
hx-target="#park-results"
|
||||||
hx-push-url="true"
|
hx-push-url="true"
|
||||||
@@ -47,25 +46,25 @@
|
|||||||
{% block filter_section %}
|
{% block filter_section %}
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<div class="max-w-3xl mx-auto relative mb-8">
|
<div class="max-w-3xl mx-auto relative mb-8">
|
||||||
<div class="w-full relative">
|
<label for="search" class="sr-only">Search parks</label>
|
||||||
<form hx-get="{% url 'parks:park_list' %}"
|
<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-target="#park-results"
|
||||||
hx-push-url="true"
|
hx-push-url="true"
|
||||||
hx-trigger="change from:.park-search">
|
hx-indicator="#search-indicator"
|
||||||
{% csrf_token %}
|
value="{{ request.GET.search|default:'' }}"
|
||||||
{{ search_form.park }}
|
aria-label="Search parks">
|
||||||
</form>
|
<div class="absolute inset-y-0 right-0 flex items-center pr-3">
|
||||||
|
<div id="search-indicator" class="htmx-indicator">
|
||||||
<!-- Loading indicator -->
|
|
||||||
<div id="search-indicator"
|
|
||||||
class="htmx-indicator absolute right-3 top-3"
|
|
||||||
role="status"
|
|
||||||
aria-label="Loading search results">
|
|
||||||
<svg class="h-5 w-5 text-gray-400 animate-spin" viewBox="0 0 24 24" aria-hidden="true">
|
<svg class="h-5 w-5 text-gray-400 animate-spin" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none"/>
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none"/>
|
||||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
|
||||||
</svg>
|
</svg>
|
||||||
<span class="sr-only">Searching...</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -93,3 +92,7 @@
|
|||||||
{% include "parks/partials/park_list_item.html" with parks=parks view_mode=view_mode|default:'grid' %}
|
{% include "parks/partials/park_list_item.html" with parks=parks view_mode=view_mode|default:'grid' %}
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script src="{% static 'parks/js/search.js' %}"></script>
|
||||||
|
{% endblock %}
|
||||||
@@ -11,33 +11,80 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% 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 %}
|
{% 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">
|
<article class="park-card group relative bg-white border rounded-lg transition-all duration-200 ease-in-out hover:shadow-lg {% if view_mode == 'list' %}flex gap-4 p-4{% endif %}"
|
||||||
<div class="p-4">
|
data-testid="park-card"
|
||||||
<h2 class="mb-2 text-xl font-bold">
|
data-park-id="{{ park.id }}"
|
||||||
<a href="{% url 'parks:park_detail' park.slug %}" class="text-gray-900 hover:text-blue-600 dark:text-white dark:hover:text-blue-400">
|
data-view-mode="{{ view_mode|default:'grid' }}">
|
||||||
{{ park.name }}
|
|
||||||
</a>
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-2">
|
<a href="{% url 'parks:park_detail' park.slug %}"
|
||||||
<span class="status-badge status-{{ park.status|lower }}">
|
class="absolute inset-0 z-0"
|
||||||
{{ park.get_status_display }}
|
aria-label="View details for {{ park.name }}"></a>
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if park.owner %}
|
<div class="relative z-10 {% if view_mode == 'grid' %}aspect-video{% endif %}">
|
||||||
<div class="mt-4 text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300">
|
{% if park.photos.exists %}
|
||||||
<a href="{% url 'companies:company_detail' park.owner.slug %}">
|
<img src="{{ park.photos.first.image.url }}"
|
||||||
{{ park.owner.name }}
|
alt="Photo of {{ park.name }}"
|
||||||
</a>
|
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>
|
</div>
|
||||||
{% endif %}
|
{% 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>
|
||||||
|
|
||||||
|
<div class="mt-2 flex flex-wrap gap-2">
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium {{ park.get_status_color }} status-badge"
|
||||||
|
data-testid="park-status">
|
||||||
|
{{ park.get_status_display }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{% if park.opening_date %}
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800"
|
||||||
|
data-testid="park-opening-date">
|
||||||
|
Opened {{ park.opening_date|date:"Y" }}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if park.current_ride_count %}
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800"
|
||||||
|
data-testid="park-ride-count">
|
||||||
|
{{ park.current_ride_count }} ride{{ park.current_ride_count|pluralize }}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if park.current_coaster_count %}
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800"
|
||||||
|
data-testid="park-coaster-count">
|
||||||
|
{{ park.current_coaster_count }} coaster{{ park.current_coaster_count|pluralize }}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
<div class="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 %}
|
{% if search_query %}
|
||||||
No parks found matching "{{ search_query }}". Try adjusting your search terms.
|
No parks found matching "{{ search_query }}". Try adjusting your search terms.
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|||||||
@@ -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 django.urls import path, include
|
||||||
from . import views, views_search
|
from . import views
|
||||||
from rides.views import ParkSingleCategoryListView
|
from rides.views import ParkSingleCategoryListView
|
||||||
|
|
||||||
app_name = "parks"
|
app_name = "parks"
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
# Park views with autocomplete search
|
# Park views
|
||||||
path("", views_search.ParkSearchView.as_view(), name="park_list"),
|
path("", views.ParkListView.as_view(), name="park_list"),
|
||||||
path("create/", views.ParkCreateView.as_view(), name="park_create"),
|
path("create/", views.ParkCreateView.as_view(), name="park_create"),
|
||||||
|
|
||||||
# Add park button endpoint (moved before park detail pattern)
|
# Add park button endpoint (moved before park detail pattern)
|
||||||
@@ -18,8 +18,6 @@ urlpatterns = [
|
|||||||
|
|
||||||
# Areas and search endpoints for HTMX
|
# Areas and search endpoints for HTMX
|
||||||
path("areas/", views.get_park_areas, name="get_park_areas"),
|
path("areas/", views.get_park_areas, name="get_park_areas"),
|
||||||
path("suggest_parks/", views_search.suggest_parks, name="suggest_parks"),
|
|
||||||
|
|
||||||
path("search/", views.search_parks, name="search_parks"),
|
path("search/", views.search_parks, name="search_parks"),
|
||||||
|
|
||||||
# Park detail and related views
|
# Park detail and related views
|
||||||
|
|||||||
541
parks/views.py
541
parks/views.py
@@ -1,120 +1,55 @@
|
|||||||
from .querysets import get_base_park_queryset
|
|
||||||
from search.mixins import HTMXFilterableMixin
|
|
||||||
from reviews.models import Review
|
|
||||||
from location.models import Location
|
|
||||||
from media.models import Photo
|
|
||||||
from moderation.models import EditSubmission
|
|
||||||
from moderation.mixins import EditSubmissionMixin, PhotoSubmissionMixin, HistoryMixin
|
|
||||||
from core.views import SlugRedirectMixin
|
|
||||||
from .filters import ParkFilter
|
|
||||||
from .forms import ParkForm
|
|
||||||
from .models import Park, ParkArea
|
|
||||||
from django.http import HttpResponseRedirect, HttpResponse, HttpRequest, JsonResponse
|
|
||||||
from django.core.exceptions import ObjectDoesNotExist
|
|
||||||
from django.contrib import messages
|
|
||||||
from django.contrib.contenttypes.models import ContentType
|
|
||||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
|
||||||
from django.db.models import Q, Count, QuerySet
|
|
||||||
from django.urls import reverse
|
|
||||||
from django.shortcuts import get_object_or_404, render
|
|
||||||
from decimal import InvalidOperation
|
|
||||||
from django.views.generic import DetailView, ListView, CreateView, UpdateView
|
|
||||||
import requests
|
|
||||||
from decimal import Decimal, ROUND_DOWN
|
from decimal import Decimal, ROUND_DOWN
|
||||||
from typing import Any, Optional, cast, Literal
|
from typing import Any, Optional, cast, Literal
|
||||||
|
from django.views.generic import DetailView, ListView, CreateView, UpdateView
|
||||||
# Constants
|
from django.shortcuts import get_object_or_404, render
|
||||||
PARK_DETAIL_URL = "parks:park_detail"
|
from django.urls import reverse
|
||||||
PARK_LIST_ITEM_TEMPLATE = "parks/partials/park_list_item.html"
|
from django.db.models import Q, Count, QuerySet
|
||||||
REQUIRED_FIELDS_ERROR = "Please correct the errors below. Required fields are marked with an asterisk (*)."
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
ALLOWED_ROLES = ["MODERATOR", "ADMIN", "SUPERUSER"]
|
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"]
|
ViewMode = Literal["grid", "list"]
|
||||||
|
|
||||||
|
|
||||||
def normalize_osm_result(result: dict) -> dict:
|
|
||||||
"""Normalize OpenStreetMap result to a consistent format with enhanced address details"""
|
|
||||||
from .location_utils import get_english_name, normalize_coordinate
|
|
||||||
|
|
||||||
# Get address details
|
|
||||||
address = result.get('address', {})
|
|
||||||
|
|
||||||
# Normalize coordinates
|
|
||||||
lat = normalize_coordinate(float(result.get('lat')), 9, 6)
|
|
||||||
lon = normalize_coordinate(float(result.get('lon')), 10, 6)
|
|
||||||
|
|
||||||
# Get English names where possible
|
|
||||||
name = ''
|
|
||||||
if 'namedetails' in result:
|
|
||||||
name = get_english_name(result['namedetails'])
|
|
||||||
|
|
||||||
# Build street address from available components
|
|
||||||
street_parts = []
|
|
||||||
if address.get('house_number'):
|
|
||||||
street_parts.append(address['house_number'])
|
|
||||||
if address.get('road') or address.get('street'):
|
|
||||||
street_parts.append(address.get('road') or address.get('street'))
|
|
||||||
elif address.get('pedestrian'):
|
|
||||||
street_parts.append(address['pedestrian'])
|
|
||||||
elif address.get('footway'):
|
|
||||||
street_parts.append(address['footway'])
|
|
||||||
|
|
||||||
# Handle additional address components
|
|
||||||
suburb = address.get('suburb', '')
|
|
||||||
district = address.get('district', '')
|
|
||||||
neighborhood = address.get('neighbourhood', '')
|
|
||||||
|
|
||||||
# Build city from available components
|
|
||||||
city = (address.get('city') or
|
|
||||||
address.get('town') or
|
|
||||||
address.get('village') or
|
|
||||||
address.get('municipality') or
|
|
||||||
'')
|
|
||||||
|
|
||||||
# Get detailed state/region information
|
|
||||||
state = (address.get('state') or
|
|
||||||
address.get('province') or
|
|
||||||
address.get('region') or
|
|
||||||
'')
|
|
||||||
|
|
||||||
# Get postal code with fallbacks
|
|
||||||
postal_code = (address.get('postcode') or
|
|
||||||
address.get('postal_code') or
|
|
||||||
'')
|
|
||||||
|
|
||||||
return {
|
|
||||||
'display_name': name or result.get('display_name', ''),
|
|
||||||
'lat': lat,
|
|
||||||
'lon': lon,
|
|
||||||
'street': ' '.join(street_parts).strip(),
|
|
||||||
'suburb': suburb,
|
|
||||||
'district': district,
|
|
||||||
'neighborhood': neighborhood,
|
|
||||||
'city': city,
|
|
||||||
'state': state,
|
|
||||||
'country': address.get('country', ''),
|
|
||||||
'postal_code': postal_code,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def get_view_mode(request: HttpRequest) -> ViewMode:
|
def get_view_mode(request: HttpRequest) -> ViewMode:
|
||||||
"""Get the current view mode from request, defaulting to grid"""
|
"""Get the current view mode from request, defaulting to grid"""
|
||||||
view_mode = request.GET.get('view_mode', 'grid')
|
view_mode = request.GET.get('view_mode', 'grid')
|
||||||
return cast(ViewMode, 'list' if view_mode == 'list' else 'grid')
|
return cast(ViewMode, 'list' if view_mode == 'list' else 'grid')
|
||||||
|
|
||||||
|
|
||||||
|
def get_base_park_queryset() -> QuerySet[Park]:
|
||||||
|
"""Get base queryset with all needed annotations and prefetches"""
|
||||||
|
return (
|
||||||
|
Park.objects.select_related('owner')
|
||||||
|
.prefetch_related('location', 'photos', 'rides')
|
||||||
|
.annotate(
|
||||||
|
current_ride_count=Count('rides', distinct=True),
|
||||||
|
current_coaster_count=Count('rides', filter=Q(rides__category="RC"), distinct=True)
|
||||||
|
)
|
||||||
|
.order_by('name')
|
||||||
|
)
|
||||||
|
|
||||||
def add_park_button(request: HttpRequest) -> HttpResponse:
|
def add_park_button(request: HttpRequest) -> HttpResponse:
|
||||||
"""Return the add park button partial template"""
|
"""Return the add park button partial template"""
|
||||||
return render(request, "parks/partials/add_park_button.html")
|
return render(request, "parks/partials/add_park_button.html")
|
||||||
|
|
||||||
|
|
||||||
def park_actions(request: HttpRequest, slug: str) -> HttpResponse:
|
def park_actions(request: HttpRequest, slug: str) -> HttpResponse:
|
||||||
"""Return the park actions partial template"""
|
"""Return the park actions partial template"""
|
||||||
park = get_object_or_404(Park, slug=slug)
|
park = get_object_or_404(Park, slug=slug)
|
||||||
return render(request, "parks/partials/park_actions.html", {"park": park})
|
return render(request, "parks/partials/park_actions.html", {"park": park})
|
||||||
|
|
||||||
|
|
||||||
def get_park_areas(request: HttpRequest) -> HttpResponse:
|
def get_park_areas(request: HttpRequest) -> HttpResponse:
|
||||||
"""Return park areas as options for a select element"""
|
"""Return park areas as options for a select element"""
|
||||||
park_id = request.GET.get('park')
|
park_id = request.GET.get('park')
|
||||||
@@ -133,7 +68,6 @@ def get_park_areas(request: HttpRequest) -> HttpResponse:
|
|||||||
except Park.DoesNotExist:
|
except Park.DoesNotExist:
|
||||||
return HttpResponse('<option value="">Invalid park selected</option>')
|
return HttpResponse('<option value="">Invalid park selected</option>')
|
||||||
|
|
||||||
|
|
||||||
def location_search(request: HttpRequest) -> JsonResponse:
|
def location_search(request: HttpRequest) -> JsonResponse:
|
||||||
"""Search for locations using OpenStreetMap Nominatim API"""
|
"""Search for locations using OpenStreetMap Nominatim API"""
|
||||||
query = request.GET.get("q", "")
|
query = request.GET.get("q", "")
|
||||||
@@ -156,8 +90,7 @@ def location_search(request: HttpRequest) -> JsonResponse:
|
|||||||
|
|
||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
results = response.json()
|
results = response.json()
|
||||||
normalized_results = [normalize_osm_result(
|
normalized_results = [normalize_osm_result(result) for result in results]
|
||||||
result) for result in results]
|
|
||||||
valid_results = [
|
valid_results = [
|
||||||
r for r in normalized_results
|
r for r in normalized_results
|
||||||
if r["lat"] is not None and r["lon"] is not None
|
if r["lat"] is not None and r["lon"] is not None
|
||||||
@@ -166,7 +99,6 @@ def location_search(request: HttpRequest) -> JsonResponse:
|
|||||||
|
|
||||||
return JsonResponse({"results": []})
|
return JsonResponse({"results": []})
|
||||||
|
|
||||||
|
|
||||||
def reverse_geocode(request: HttpRequest) -> JsonResponse:
|
def reverse_geocode(request: HttpRequest) -> JsonResponse:
|
||||||
"""Reverse geocode coordinates using OpenStreetMap Nominatim API"""
|
"""Reverse geocode coordinates using OpenStreetMap Nominatim API"""
|
||||||
try:
|
try:
|
||||||
@@ -280,8 +212,6 @@ def search_parks(request: HttpRequest) -> HttpResponse:
|
|||||||
if not search_query:
|
if not search_query:
|
||||||
return HttpResponse('')
|
return HttpResponse('')
|
||||||
|
|
||||||
# Get current view mode from request
|
|
||||||
current_view_mode = request.GET.get('view_mode', 'grid')
|
|
||||||
park_filter = ParkFilter({
|
park_filter = ParkFilter({
|
||||||
'search': search_query
|
'search': search_query
|
||||||
}, queryset=get_base_park_queryset())
|
}, queryset=get_base_park_queryset())
|
||||||
@@ -292,10 +222,10 @@ def search_parks(request: HttpRequest) -> HttpResponse:
|
|||||||
|
|
||||||
response = render(
|
response = render(
|
||||||
request,
|
request,
|
||||||
PARK_LIST_ITEM_TEMPLATE,
|
"parks/partials/park_list_item.html",
|
||||||
{
|
{
|
||||||
"parks": parks,
|
"parks": parks,
|
||||||
"view_mode": current_view_mode,
|
"view_mode": get_view_mode(request),
|
||||||
"search_query": search_query,
|
"search_query": search_query,
|
||||||
"is_search": True
|
"is_search": True
|
||||||
}
|
}
|
||||||
@@ -306,7 +236,7 @@ def search_parks(request: HttpRequest) -> HttpResponse:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
response = render(
|
response = render(
|
||||||
request,
|
request,
|
||||||
PARK_LIST_ITEM_TEMPLATE,
|
"parks/partials/park_list_item.html",
|
||||||
{
|
{
|
||||||
"parks": [],
|
"parks": [],
|
||||||
"error": f"Error performing search: {str(e)}",
|
"error": f"Error performing search: {str(e)}",
|
||||||
@@ -316,7 +246,6 @@ def search_parks(request: HttpRequest) -> HttpResponse:
|
|||||||
response['HX-Trigger'] = 'searchError'
|
response['HX-Trigger'] = 'searchError'
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
class ParkCreateView(LoginRequiredMixin, CreateView):
|
class ParkCreateView(LoginRequiredMixin, CreateView):
|
||||||
model = Park
|
model = Park
|
||||||
form_class = ParkForm
|
form_class = ParkForm
|
||||||
@@ -330,8 +259,7 @@ class ParkCreateView(LoginRequiredMixin, CreateView):
|
|||||||
data["opening_date"] = data["opening_date"].isoformat()
|
data["opening_date"] = data["opening_date"].isoformat()
|
||||||
if data.get("closing_date"):
|
if data.get("closing_date"):
|
||||||
data["closing_date"] = data["closing_date"].isoformat()
|
data["closing_date"] = data["closing_date"].isoformat()
|
||||||
decimal_fields = ["latitude", "longitude",
|
decimal_fields = ["latitude", "longitude", "size_acres", "average_rating"]
|
||||||
"size_acres", "average_rating"]
|
|
||||||
for field in decimal_fields:
|
for field in decimal_fields:
|
||||||
if data.get(field):
|
if data.get(field):
|
||||||
data[field] = str(data[field])
|
data[field] = str(data[field])
|
||||||
@@ -364,7 +292,324 @@ class ParkCreateView(LoginRequiredMixin, CreateView):
|
|||||||
|
|
||||||
if hasattr(self.request.user, "role") and getattr(
|
if hasattr(self.request.user, "role") and getattr(
|
||||||
self.request.user, "role", None
|
self.request.user, "role", None
|
||||||
) in 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:
|
try:
|
||||||
self.object = form.save()
|
self.object = form.save()
|
||||||
submission.object_id = self.object.id
|
submission.object_id = self.object.id
|
||||||
@@ -424,7 +669,14 @@ class ParkCreateView(LoginRequiredMixin, CreateView):
|
|||||||
messages.success(
|
messages.success(
|
||||||
self.request,
|
self.request,
|
||||||
"Your park submission has been sent for review. "
|
"Your park submission has been sent for review. "
|
||||||
"You will be notified when it is approved."
|
"You will be notified when it is approved.",
|
||||||
|
)
|
||||||
|
return HttpResponseRedirect(reverse("parks:park_list"))
|
||||||
|
|
||||||
|
def form_invalid(self, form: ParkForm) -> HttpResponse:
|
||||||
|
messages.error(
|
||||||
|
self.request,
|
||||||
|
"Please correct the errors below. Required fields are marked with an asterisk (*).",
|
||||||
)
|
)
|
||||||
for field, errors in form.errors.items():
|
for field, errors in form.errors.items():
|
||||||
for error in errors:
|
for error in errors:
|
||||||
@@ -432,7 +684,7 @@ class ParkCreateView(LoginRequiredMixin, CreateView):
|
|||||||
return super().form_invalid(form)
|
return super().form_invalid(form)
|
||||||
|
|
||||||
def get_success_url(self) -> str:
|
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):
|
class ParkUpdateView(LoginRequiredMixin, UpdateView):
|
||||||
@@ -488,7 +740,7 @@ class ParkUpdateView(LoginRequiredMixin, UpdateView):
|
|||||||
|
|
||||||
if hasattr(self.request.user, "role") and getattr(
|
if hasattr(self.request.user, "role") and getattr(
|
||||||
self.request.user, "role", None
|
self.request.user, "role", None
|
||||||
) in ALLOWED_ROLES:
|
) in ["MODERATOR", "ADMIN", "SUPERUSER"]:
|
||||||
try:
|
try:
|
||||||
self.object = form.save()
|
self.object = form.save()
|
||||||
submission.status = "APPROVED"
|
submission.status = "APPROVED"
|
||||||
@@ -556,13 +808,13 @@ class ParkUpdateView(LoginRequiredMixin, UpdateView):
|
|||||||
"You will be notified when they are approved.",
|
"You will be notified when they are approved.",
|
||||||
)
|
)
|
||||||
return HttpResponseRedirect(
|
return HttpResponseRedirect(
|
||||||
reverse(PARK_DETAIL_URL, kwargs={"slug": self.object.slug})
|
reverse("parks:park_detail", kwargs={"slug": self.object.slug})
|
||||||
)
|
)
|
||||||
|
|
||||||
def form_invalid(self, form: ParkForm) -> HttpResponse:
|
def form_invalid(self, form: ParkForm) -> HttpResponse:
|
||||||
messages.error(
|
messages.error(
|
||||||
self.request,
|
self.request,
|
||||||
REQUIRED_FIELDS_ERROR
|
"Please correct the errors below. Required fields are marked with an asterisk (*).",
|
||||||
)
|
)
|
||||||
for field, errors in form.errors.items():
|
for field, errors in form.errors.items():
|
||||||
for error in errors:
|
for error in errors:
|
||||||
@@ -570,62 +822,7 @@ class ParkUpdateView(LoginRequiredMixin, UpdateView):
|
|||||||
return super().form_invalid(form)
|
return super().form_invalid(form)
|
||||||
|
|
||||||
def get_success_url(self) -> str:
|
def get_success_url(self) -> str:
|
||||||
return reverse(PARK_DETAIL_URL, kwargs={"slug": self.object.slug})
|
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 PARK_DETAIL_URL
|
|
||||||
|
|
||||||
|
|
||||||
class ParkAreaDetailView(
|
class ParkAreaDetailView(
|
||||||
@@ -633,7 +830,7 @@ class ParkAreaDetailView(
|
|||||||
EditSubmissionMixin,
|
EditSubmissionMixin,
|
||||||
PhotoSubmissionMixin,
|
PhotoSubmissionMixin,
|
||||||
HistoryMixin,
|
HistoryMixin,
|
||||||
DetailView
|
DetailView,
|
||||||
):
|
):
|
||||||
model = ParkArea
|
model = ParkArea
|
||||||
template_name = "parks/area_detail.html"
|
template_name = "parks/area_detail.html"
|
||||||
@@ -657,7 +854,7 @@ class ParkAreaDetailView(
|
|||||||
return context
|
return context
|
||||||
|
|
||||||
def get_redirect_url_pattern(self) -> str:
|
def get_redirect_url_pattern(self) -> str:
|
||||||
return PARK_DETAIL_URL
|
return "parks:park_detail"
|
||||||
|
|
||||||
def get_redirect_url_kwargs(self) -> dict[str, str]:
|
def get_redirect_url_kwargs(self) -> dict[str, str]:
|
||||||
area = cast(ParkArea, self.object)
|
area = cast(ParkArea, self.object)
|
||||||
|
|||||||
@@ -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",
|
"playwright>=1.41.0",
|
||||||
"pytest-playwright>=0.4.3",
|
"pytest-playwright>=0.4.3",
|
||||||
"django-pghistory>=3.5.2",
|
"django-pghistory>=3.5.2",
|
||||||
"django-htmx-autocomplete>=1.0.5",
|
|
||||||
"wiki>=0.11.2",
|
|
||||||
"django-mptt>=0.16.0",
|
|
||||||
"django-nyt>=1.4.1",
|
|
||||||
"sorl-thumbnail>=12.11.0",
|
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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 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 #}
|
{# Mobile Filter Toggle #}
|
||||||
<div class="lg:hidden bg-white rounded-lg shadow p-4 mb-4">
|
<div class="lg:hidden">
|
||||||
<button @click="open = !open" type="button" class="w-full flex items-center justify-between p-2">
|
<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">
|
<span class="font-medium text-gray-900">Filters</span>
|
||||||
<span class="mr-2">
|
<span class="ml-6 flex items-center">
|
||||||
<svg class="w-5 h-5 inline-block" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5" x-show="!open" fill="currentColor" viewBox="0 0 20 20">
|
||||||
<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>
|
<path d="M10 6L16 12H4L10 6Z"/>
|
||||||
</svg>
|
</svg>
|
||||||
</span>
|
<svg class="w-5 h-5" x-show="open" fill="currentColor" viewBox="0 0 20 20">
|
||||||
Filter Options
|
<path d="M10 14L4 8H16L10 14Z"/>
|
||||||
</span>
|
|
||||||
<span class="text-gray-500">
|
|
||||||
<svg class="w-5 h-5 transition-transform duration-200" :class="{'rotate-180': open}" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"></path>
|
|
||||||
</svg>
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -23,23 +18,20 @@
|
|||||||
|
|
||||||
{# Filter Form #}
|
{# Filter Form #}
|
||||||
<form hx-get="{{ request.path }}"
|
<form hx-get="{{ request.path }}"
|
||||||
hx-trigger="change delay:500ms"
|
hx-trigger="change delay:500ms, submit"
|
||||||
hx-target="#results-container"
|
hx-target="#results-container"
|
||||||
hx-push-url="true"
|
hx-push-url="true"
|
||||||
class="space-y-6"
|
class="mt-4 lg:mt-0"
|
||||||
x-show="open || $screen('lg')"
|
x-show="open || $screen('lg')"
|
||||||
x-transition>
|
x-transition>
|
||||||
|
|
||||||
{# Active Filters Summary #}
|
{# Active Filters Summary #}
|
||||||
{% if applied_filters %}
|
{% if applied_filters %}
|
||||||
<div class="bg-blue-50 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 class="flex justify-between items-center">
|
||||||
<div>
|
|
||||||
<h3 class="text-sm font-medium text-blue-800">Active Filters</h3>
|
<h3 class="text-sm font-medium text-blue-800">Active Filters</h3>
|
||||||
<p class="text-xs text-blue-600 mt-1">{{ applied_filters|length }} filter{{ applied_filters|length|pluralize }} applied</p>
|
|
||||||
</div>
|
|
||||||
<a href="{{ request.path }}"
|
<a href="{{ request.path }}"
|
||||||
class="text-sm 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-get="{{ request.path }}"
|
||||||
hx-target="#results-container"
|
hx-target="#results-container"
|
||||||
hx-push-url="true">
|
hx-push-url="true">
|
||||||
@@ -50,35 +42,21 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{# Filter Groups #}
|
{# 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 %}
|
{% for fieldset in filter.form|groupby_filters %}
|
||||||
<div class="p-6" x-data="{ expanded: true }">
|
<div class="border-b border-gray-200 pb-4">
|
||||||
{# Group Header #}
|
<h3 class="text-sm font-medium text-gray-900 mb-3">{{ fieldset.name }}</h3>
|
||||||
<button type="button"
|
<div class="space-y-3">
|
||||||
@click="expanded = !expanded"
|
|
||||||
class="w-full flex justify-between items-center text-left">
|
|
||||||
<h3 class="text-lg font-medium text-gray-900">{{ fieldset.name }}</h3>
|
|
||||||
<svg class="w-5 h-5 text-gray-500 transform transition-transform duration-200"
|
|
||||||
:class="{'rotate-180': !expanded}"
|
|
||||||
fill="currentColor"
|
|
||||||
viewBox="0 0 20 20">
|
|
||||||
<path d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{# Group Content #}
|
|
||||||
<div class="mt-4 space-y-4" x-show="expanded" x-collapse>
|
|
||||||
{% for field in fieldset.fields %}
|
{% for field in fieldset.fields %}
|
||||||
<div class="filter-field">
|
<div>
|
||||||
<label for="{{ field.id_for_label }}"
|
<label for="{{ field.id_for_label }}" class="text-sm text-gray-600">
|
||||||
class="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
{{ field.label }}
|
{{ field.label }}
|
||||||
</label>
|
</label>
|
||||||
<div class="mt-1">
|
<div class="mt-1">
|
||||||
{{ field|add_field_classes }}
|
{{ field }}
|
||||||
</div>
|
</div>
|
||||||
{% if field.help_text %}
|
{% 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 %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -87,25 +65,17 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# Mobile Apply Button #}
|
{# Submit Button - Only visible on mobile #}
|
||||||
<div class="lg:hidden">
|
<div class="mt-4 lg:hidden">
|
||||||
<button type="submit"
|
<button type="submit"
|
||||||
class="w-full bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 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
|
Apply Filters
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</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 defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||||
<script>
|
{% endblock %}
|
||||||
document.addEventListener('alpine:init', () => {
|
|
||||||
Alpine.data('filterForm', () => ({
|
|
||||||
expanded: true,
|
|
||||||
toggle() {
|
|
||||||
this.expanded = !this.expanded
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
@@ -32,18 +32,17 @@ def groupby_filters(form: Form) -> List[Dict[str, Any]]:
|
|||||||
"""
|
"""
|
||||||
groups = []
|
groups = []
|
||||||
|
|
||||||
# Define groups and their patterns with specific ordering
|
# Define groups and their patterns
|
||||||
group_patterns = {
|
group_patterns = {
|
||||||
'Quick Search': lambda f: f.name in ['search', 'q'],
|
'Search': lambda f: f.name in ['search', 'q'],
|
||||||
'Park Details': lambda f: f.name in ['status', 'has_owner', 'owner'],
|
|
||||||
'Attractions': lambda f: any(x in f.name for x in ['rides', 'coasters']),
|
|
||||||
'Park Size': lambda f: 'size' in f.name,
|
|
||||||
'Location': lambda f: f.name.startswith('location') or 'address' in f.name,
|
'Location': lambda f: f.name.startswith('location') or 'address' in f.name,
|
||||||
'Ratings': lambda f: 'rating' in f.name,
|
'Dates': lambda f: any(x in f.name for x in ['date', 'created', 'updated']),
|
||||||
'Opening Info': lambda f: 'opening' in f.name or 'date' in f.name,
|
'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()}
|
grouped_fields: Dict[str, List] = {name: [] for name in group_patterns.keys()}
|
||||||
ungrouped = []
|
ungrouped = []
|
||||||
|
|
||||||
@@ -58,7 +57,7 @@ def groupby_filters(form: Form) -> List[Dict[str, Any]]:
|
|||||||
if not grouped:
|
if not grouped:
|
||||||
ungrouped.append(field)
|
ungrouped.append(field)
|
||||||
|
|
||||||
# Build final groups list, maintaining order and only including non-empty groups
|
# Build final groups list, only including non-empty groups
|
||||||
for name, fields in grouped_fields.items():
|
for name, fields in grouped_fields.items():
|
||||||
if fields:
|
if fields:
|
||||||
groups.append({
|
groups.append({
|
||||||
@@ -69,7 +68,7 @@ def groupby_filters(form: Form) -> List[Dict[str, Any]]:
|
|||||||
# Add ungrouped fields at the end if any exist
|
# Add ungrouped fields at the end if any exist
|
||||||
if ungrouped:
|
if ungrouped:
|
||||||
groups.append({
|
groups.append({
|
||||||
'name': 'Other Filters',
|
'name': 'Other',
|
||||||
'fields': ungrouped
|
'fields': ungrouped
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -87,26 +86,15 @@ def add_field_classes(field: Any) -> Any:
|
|||||||
"""
|
"""
|
||||||
Add appropriate Tailwind classes based on field type
|
Add appropriate Tailwind classes based on field type
|
||||||
"""
|
"""
|
||||||
base_classes = "transition duration-150 ease-in-out "
|
|
||||||
|
|
||||||
classes = {
|
classes = {
|
||||||
'default': 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': base_classes + 'h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-2 focus:ring-blue-500',
|
'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': base_classes + 'h-4 w-4 border-gray-300 text-blue-600 focus:ring-2 focus:ring-blue-500',
|
'radio': 'border-gray-300 text-blue-600 shadow-sm focus:border-blue-300 focus:ring focus:ring-offset-0 focus:ring-blue-200 focus:ring-opacity-50',
|
||||||
'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',
|
'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': base_classes + 'mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 focus:ring-opacity-50',
|
'multiselect': 'mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-300 focus:ring focus:ring-blue-200 focus:ring-opacity-50',
|
||||||
'range': base_classes + 'mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 focus:ring-opacity-50',
|
|
||||||
'dateinput': base_classes + 'mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 focus:ring-opacity-50',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
field_type = get_field_type(field)
|
field_type = get_field_type(field)
|
||||||
css_class = classes.get(field_type, classes['default'])
|
css_class = classes.get(field_type, classes['default'])
|
||||||
|
|
||||||
current_attrs = field.field.widget.attrs
|
return field.as_widget(attrs={'class': css_class})
|
||||||
current_attrs['class'] = css_class
|
|
||||||
|
|
||||||
# Add specific attributes for certain field types
|
|
||||||
if field_type == 'dateinput':
|
|
||||||
current_attrs['type'] = 'date'
|
|
||||||
|
|
||||||
return field.as_widget(attrs=current_attrs)
|
|
||||||
@@ -2325,11 +2325,6 @@ select {
|
|||||||
margin-bottom: auto;
|
margin-bottom: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.-mx-4 {
|
|
||||||
margin-left: -1rem;
|
|
||||||
margin-right: -1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.-mb-px {
|
.-mb-px {
|
||||||
margin-bottom: -1px;
|
margin-bottom: -1px;
|
||||||
}
|
}
|
||||||
@@ -2446,10 +2441,6 @@ select {
|
|||||||
margin-top: auto;
|
margin-top: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mt-8 {
|
|
||||||
margin-top: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.block {
|
.block {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
@@ -2530,10 +2521,6 @@ select {
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.h-64 {
|
|
||||||
height: 16rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.max-h-60 {
|
.max-h-60 {
|
||||||
max-height: 15rem;
|
max-height: 15rem;
|
||||||
}
|
}
|
||||||
@@ -2591,18 +2578,10 @@ select {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.w-48 {
|
|
||||||
width: 12rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.min-w-\[200px\] {
|
.min-w-\[200px\] {
|
||||||
min-width: 200px;
|
min-width: 200px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.min-w-full {
|
|
||||||
min-width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.max-w-2xl {
|
.max-w-2xl {
|
||||||
max-width: 42rem;
|
max-width: 42rem;
|
||||||
}
|
}
|
||||||
@@ -2643,10 +2622,6 @@ select {
|
|||||||
max-width: 20rem;
|
max-width: 20rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.max-w-full {
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.flex-1 {
|
.flex-1 {
|
||||||
flex: 1 1 0%;
|
flex: 1 1 0%;
|
||||||
}
|
}
|
||||||
@@ -2723,14 +2698,6 @@ select {
|
|||||||
resize: none;
|
resize: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-decimal {
|
|
||||||
list-style-type: decimal;
|
|
||||||
}
|
|
||||||
|
|
||||||
.list-disc {
|
|
||||||
list-style-type: disc;
|
|
||||||
}
|
|
||||||
|
|
||||||
.grid-cols-1 {
|
.grid-cols-1 {
|
||||||
grid-template-columns: repeat(1, minmax(0, 1fr));
|
grid-template-columns: repeat(1, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
@@ -2857,17 +2824,6 @@ select {
|
|||||||
margin-bottom: calc(1.5rem * var(--tw-space-y-reverse));
|
margin-bottom: calc(1.5rem * var(--tw-space-y-reverse));
|
||||||
}
|
}
|
||||||
|
|
||||||
.divide-y > :not([hidden]) ~ :not([hidden]) {
|
|
||||||
--tw-divide-y-reverse: 0;
|
|
||||||
border-top-width: calc(1px * calc(1 - var(--tw-divide-y-reverse)));
|
|
||||||
border-bottom-width: calc(1px * var(--tw-divide-y-reverse));
|
|
||||||
}
|
|
||||||
|
|
||||||
.divide-gray-200 > :not([hidden]) ~ :not([hidden]) {
|
|
||||||
--tw-divide-opacity: 1;
|
|
||||||
border-color: rgb(229 231 235 / var(--tw-divide-opacity));
|
|
||||||
}
|
|
||||||
|
|
||||||
.overflow-auto {
|
.overflow-auto {
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
@@ -2876,18 +2832,10 @@ select {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.overflow-x-auto {
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.overflow-y-auto {
|
.overflow-y-auto {
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.whitespace-nowrap {
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rounded {
|
.rounded {
|
||||||
border-radius: 0.25rem;
|
border-radius: 0.25rem;
|
||||||
}
|
}
|
||||||
@@ -2958,10 +2906,6 @@ select {
|
|||||||
border-top-width: 1px;
|
border-top-width: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.border-l-4 {
|
|
||||||
border-left-width: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.border-dashed {
|
.border-dashed {
|
||||||
border-style: dashed;
|
border-style: dashed;
|
||||||
}
|
}
|
||||||
@@ -3334,14 +3278,6 @@ select {
|
|||||||
padding-top: 0.5rem;
|
padding-top: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pl-4 {
|
|
||||||
padding-left: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pt-4 {
|
|
||||||
padding-top: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-left {
|
.text-left {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
@@ -3414,18 +3350,10 @@ select {
|
|||||||
text-transform: lowercase;
|
text-transform: lowercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
.italic {
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
.leading-tight {
|
.leading-tight {
|
||||||
line-height: 1.25;
|
line-height: 1.25;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tracking-wider {
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-blue-400 {
|
.text-blue-400 {
|
||||||
--tw-text-opacity: 1;
|
--tw-text-opacity: 1;
|
||||||
color: rgb(96 165 250 / var(--tw-text-opacity));
|
color: rgb(96 165 250 / var(--tw-text-opacity));
|
||||||
@@ -3909,21 +3837,6 @@ select {
|
|||||||
color: rgb(7 89 133 / var(--tw-text-opacity));
|
color: rgb(7 89 133 / var(--tw-text-opacity));
|
||||||
}
|
}
|
||||||
|
|
||||||
.hover\:text-red-900:hover {
|
|
||||||
--tw-text-opacity: 1;
|
|
||||||
color: rgb(127 29 29 / var(--tw-text-opacity));
|
|
||||||
}
|
|
||||||
|
|
||||||
.hover\:text-blue-400:hover {
|
|
||||||
--tw-text-opacity: 1;
|
|
||||||
color: rgb(96 165 250 / var(--tw-text-opacity));
|
|
||||||
}
|
|
||||||
|
|
||||||
.hover\:text-pink-600:hover {
|
|
||||||
--tw-text-opacity: 1;
|
|
||||||
color: rgb(219 39 119 / var(--tw-text-opacity));
|
|
||||||
}
|
|
||||||
|
|
||||||
.hover\:underline:hover {
|
.hover\:underline:hover {
|
||||||
text-decoration-line: underline;
|
text-decoration-line: underline;
|
||||||
}
|
}
|
||||||
@@ -4549,10 +4462,6 @@ select {
|
|||||||
grid-column: span 2 / span 2;
|
grid-column: span 2 / span 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lg\:mb-0 {
|
|
||||||
margin-bottom: 0px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lg\:flex {
|
.lg\:flex {
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
@@ -4561,14 +4470,6 @@ select {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lg\:w-1\/4 {
|
|
||||||
width: 25%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lg\:w-3\/4 {
|
|
||||||
width: 75%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lg\:grid-cols-3 {
|
.lg\:grid-cols-3 {
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}">
|
data-result-index="${index}">
|
||||||
<div class="font-medium text-gray-900 dark:text-white">${result.display_name || result.name || ''}</div>
|
<div class="font-medium text-gray-900 dark:text-white">${result.display_name || result.name || ''}</div>
|
||||||
<div class="text-sm text-gray-500 dark:text-gray-400">
|
<div class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
${[
|
${(result.address && result.address.city) ? result.address.city + ', ' : ''}${(result.address && result.address.country) || ''}
|
||||||
result.street,
|
|
||||||
result.city || (result.address && (result.address.city || result.address.town || result.address.village)),
|
|
||||||
result.state || (result.address && (result.address.state || result.address.region)),
|
|
||||||
result.country || (result.address && result.address.country),
|
|
||||||
result.postal_code || (result.address && result.address.postcode)
|
|
||||||
].filter(Boolean).join(', ')}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
`).join('');
|
||||||
@@ -319,12 +313,12 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
const address = {
|
const address = {
|
||||||
name: result.display_name || result.name || '',
|
name: result.display_name || result.name || '',
|
||||||
address: {
|
address: {
|
||||||
house_number: result.house_number || (result.address && result.address.house_number) || '',
|
house_number: result.address ? result.address.house_number : '',
|
||||||
road: result.street || (result.address && (result.address.road || result.address.street)) || '',
|
road: result.address ? (result.address.road || result.address.street) : '',
|
||||||
city: result.city || (result.address && (result.address.city || result.address.town || result.address.village)) || '',
|
city: result.address ? (result.address.city || result.address.town || result.address.village) : '',
|
||||||
state: result.state || (result.address && (result.address.state || result.address.region)) || '',
|
state: result.address ? (result.address.state || result.address.region) : '',
|
||||||
country: result.country || (result.address && result.address.country) || '',
|
country: result.address ? result.address.country : '',
|
||||||
postcode: result.postal_code || (result.address && result.address.postcode) || ''
|
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
|
from pathlib import Path
|
||||||
|
import os
|
||||||
|
|
||||||
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
SECRET_KEY = "django-insecure-=0)^0#h#k$0@$8$ys=^$0#h#k$0@$8$ys=^"
|
||||||
|
|
||||||
# Quick-start development settings - unsuitable for production
|
# SECURITY WARNING: don't run with debug turned on in production!
|
||||||
SECRET_KEY = 'django-insecure-key-for-development'
|
|
||||||
DEBUG = True
|
DEBUG = True
|
||||||
ALLOWED_HOSTS = []
|
|
||||||
|
CSRF_TRUSTED_ORIGINS = ["https://beta.thrillwiki.com"]
|
||||||
|
ALLOWED_HOSTS = ["*"]
|
||||||
|
|
||||||
|
# GeoDjango Settings
|
||||||
|
GDAL_LIBRARY_PATH = "/opt/homebrew/lib/libgdal.dylib"
|
||||||
|
GEOS_LIBRARY_PATH = "/opt/homebrew/lib/libgeos_c.dylib"
|
||||||
|
|
||||||
# Application definition
|
# Application definition
|
||||||
INSTALLED_APPS = [
|
INSTALLED_APPS = [
|
||||||
'django.contrib.admin',
|
"django.contrib.admin",
|
||||||
'django.contrib.auth',
|
"django.contrib.auth",
|
||||||
'django.contrib.contenttypes',
|
"django.contrib.contenttypes",
|
||||||
'django.contrib.sessions',
|
"django.contrib.sessions",
|
||||||
'django.contrib.messages',
|
"django.contrib.messages",
|
||||||
'django.contrib.staticfiles',
|
"django.contrib.staticfiles",
|
||||||
'django.contrib.sites.apps.SitesConfig',
|
"django.contrib.sites",
|
||||||
'django.contrib.humanize.apps.HumanizeConfig',
|
"django.contrib.gis", # Add GeoDjango
|
||||||
'django_nyt.apps.DjangoNytConfig',
|
"pghistory", # Add django-pghistory
|
||||||
'mptt',
|
"pgtrigger", # Required by django-pghistory
|
||||||
'sorl.thumbnail',
|
"history.apps.HistoryConfig", # History timeline app
|
||||||
'wiki.apps.WikiConfig', # Main wiki app
|
"allauth",
|
||||||
'wiki.plugins.parks.apps.ParksPluginConfig', # Parks plugin
|
"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 = [
|
MIDDLEWARE = [
|
||||||
'django.middleware.security.SecurityMiddleware',
|
"django.middleware.cache.UpdateCacheMiddleware",
|
||||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
"django.middleware.security.SecurityMiddleware",
|
||||||
'django.middleware.common.CommonMiddleware',
|
"whitenoise.middleware.WhiteNoiseMiddleware",
|
||||||
'django.middleware.csrf.CsrfViewMiddleware',
|
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
"django.middleware.common.CommonMiddleware",
|
||||||
'django.contrib.messages.middleware.MessageMiddleware',
|
"django.middleware.csrf.CsrfViewMiddleware",
|
||||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
"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 = [
|
TEMPLATES = [
|
||||||
{
|
{
|
||||||
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||||
'DIRS': [BASE_DIR / 'templates'],
|
"DIRS": [os.path.join(BASE_DIR, "templates")],
|
||||||
'APP_DIRS': True,
|
"APP_DIRS": True,
|
||||||
'OPTIONS': {
|
"OPTIONS": {
|
||||||
'context_processors': [
|
"context_processors": [
|
||||||
'django.template.context_processors.debug',
|
"django.template.context_processors.debug",
|
||||||
'django.template.context_processors.request',
|
"django.template.context_processors.request",
|
||||||
'django.contrib.auth.context_processors.auth',
|
"django.contrib.auth.context_processors.auth",
|
||||||
'django.contrib.messages.context_processors.messages',
|
"django.contrib.messages.context_processors.messages",
|
||||||
'django.template.context_processors.media',
|
"moderation.context_processors.moderation_access",
|
||||||
'django.template.context_processors.static',
|
]
|
||||||
'django.template.context_processors.tz',
|
}
|
||||||
],
|
}
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
WSGI_APPLICATION = 'thrillwiki.wsgi.application'
|
WSGI_APPLICATION = "thrillwiki.wsgi.application"
|
||||||
|
|
||||||
|
# Database
|
||||||
DATABASES = {
|
DATABASES = {
|
||||||
'default': {
|
"default": {
|
||||||
'ENGINE': 'django.db.backends.postgresql',
|
"ENGINE": "django.contrib.gis.db.backends.postgis", # Update to use PostGIS
|
||||||
'NAME': 'thrillwiki',
|
"NAME": "thrillwiki",
|
||||||
'USER': 'postgres',
|
"USER": "wiki",
|
||||||
'PASSWORD': 'postgres',
|
"PASSWORD": "thrillwiki",
|
||||||
'HOST': 'localhost',
|
"HOST": "192.168.86.3",
|
||||||
'PORT': '5432',
|
"PORT": "5432",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Cache settings
|
||||||
|
CACHES = {
|
||||||
|
"default": {
|
||||||
|
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
|
||||||
|
"LOCATION": "unique-snowflake",
|
||||||
|
"TIMEOUT": 300, # 5 minutes
|
||||||
|
"OPTIONS": {"MAX_ENTRIES": 1000},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CACHE_MIDDLEWARE_SECONDS = 1 # 5 minutes
|
||||||
|
CACHE_MIDDLEWARE_KEY_PREFIX = "thrillwiki"
|
||||||
|
|
||||||
|
# Password validation
|
||||||
AUTH_PASSWORD_VALIDATORS = [
|
AUTH_PASSWORD_VALIDATORS = [
|
||||||
{
|
{
|
||||||
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
|
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
|
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
|
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
|
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
# Wiki settings
|
|
||||||
WIKI_ACCOUNT_HANDLING = True
|
|
||||||
WIKI_ACCOUNT_SIGNUP_ALLOWED = True
|
|
||||||
WIKI_ANONYMOUS = True
|
|
||||||
WIKI_ANONYMOUS_WRITE = False
|
|
||||||
WIKI_MARKDOWN_HTML_ATTRIBUTES = True
|
|
||||||
WIKI_MARKDOWN_HTML_STYLES = True
|
|
||||||
|
|
||||||
SITE_ID = 1
|
|
||||||
|
|
||||||
# Internationalization
|
# Internationalization
|
||||||
LANGUAGE_CODE = 'en-us'
|
LANGUAGE_CODE = "en-us"
|
||||||
TIME_ZONE = 'UTC'
|
TIME_ZONE = "America/New_York"
|
||||||
USE_I18N = True
|
USE_I18N = True
|
||||||
USE_TZ = True
|
USE_TZ = True
|
||||||
|
|
||||||
# Static files (CSS, JavaScript, Images)
|
# Static files (CSS JavaScript Images)
|
||||||
STATIC_URL = 'static/'
|
STATIC_URL = "static/"
|
||||||
STATIC_ROOT = BASE_DIR / 'staticfiles'
|
STATICFILES_DIRS = [BASE_DIR / "static"]
|
||||||
STATICFILES_DIRS = [
|
STATIC_ROOT = os.path.join(BASE_DIR, "staticfiles")
|
||||||
BASE_DIR / 'static',
|
|
||||||
]
|
|
||||||
|
|
||||||
# Media files
|
# Media files
|
||||||
MEDIA_URL = '/media/'
|
MEDIA_URL = "/media/"
|
||||||
MEDIA_ROOT = BASE_DIR / 'uploads'
|
MEDIA_ROOT = os.path.join(BASE_DIR, "media")
|
||||||
|
|
||||||
# Default primary key field type
|
# 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 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.contrib import admin
|
||||||
from django.urls import path, include
|
from django.urls import path, include
|
||||||
|
from django.conf import settings
|
||||||
|
from django.conf.urls.static import static
|
||||||
|
from django.views.static import serve
|
||||||
|
from accounts import views as accounts_views
|
||||||
|
from django.views.generic import TemplateView
|
||||||
|
from .views import HomeView, SearchView
|
||||||
|
from . import views
|
||||||
|
import os
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('admin/', admin.site.urls),
|
path("admin/", admin.site.urls),
|
||||||
|
# Main app URLs
|
||||||
|
path("", HomeView.as_view(), name="home"),
|
||||||
|
# Parks and Rides URLs
|
||||||
|
path("parks/", include("parks.urls", namespace="parks")),
|
||||||
|
# Global rides URLs
|
||||||
|
path("rides/", include("rides.urls", namespace="rides")),
|
||||||
|
# Other URLs
|
||||||
|
path("reviews/", include("reviews.urls")),
|
||||||
|
path("companies/", include("companies.urls")),
|
||||||
|
path("designers/", include("designers.urls", namespace="designers")),
|
||||||
|
path("photos/", include("media.urls", namespace="photos")), # Add photos URLs
|
||||||
|
path("search/", SearchView.as_view(), name="search"),
|
||||||
|
path(
|
||||||
|
"terms/", TemplateView.as_view(template_name="pages/terms.html"), name="terms"
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"privacy/",
|
||||||
|
TemplateView.as_view(template_name="pages/privacy.html"),
|
||||||
|
name="privacy",
|
||||||
|
),
|
||||||
|
# Custom authentication URLs first (to override allauth defaults)
|
||||||
|
path("accounts/", include("accounts.urls")),
|
||||||
|
# Default allauth URLs (for social auth and other features)
|
||||||
|
path("accounts/", include("allauth.urls")),
|
||||||
|
path(
|
||||||
|
"accounts/email-required/", accounts_views.email_required, name="email_required"
|
||||||
|
),
|
||||||
|
# User profile URLs
|
||||||
|
path(
|
||||||
|
"user/<str:username>/",
|
||||||
|
accounts_views.ProfileView.as_view(),
|
||||||
|
name="user_profile",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"profile/<str:username>/", accounts_views.ProfileView.as_view(), name="profile"
|
||||||
|
),
|
||||||
|
path("settings/", accounts_views.SettingsView.as_view(), name="settings"),
|
||||||
|
# Redirect /user/ to the user's profile if logged in
|
||||||
|
path("user/", accounts_views.user_redirect_view, name="user_redirect"),
|
||||||
|
# Moderation URLs - placed after other URLs but before static/media serving
|
||||||
|
path("moderation/", include("moderation.urls", namespace="moderation")),
|
||||||
|
path("history/", include("history.urls", namespace="history")),
|
||||||
|
path(
|
||||||
|
"env-settings/",
|
||||||
|
views***REMOVED***ironment_and_settings_view,
|
||||||
|
name="environment_and_settings",
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Serve static files in development
|
||||||
|
if settings.DEBUG:
|
||||||
|
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
|
||||||
|
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||||
|
|
||||||
|
# Serve test coverage reports in development
|
||||||
|
coverage_dir = os.path.join(settings.BASE_DIR, 'tests', 'coverage_html')
|
||||||
|
if os.path.exists(coverage_dir):
|
||||||
|
urlpatterns += [
|
||||||
|
path('coverage/', serve, {
|
||||||
|
'document_root': coverage_dir,
|
||||||
|
'path': 'index.html'
|
||||||
|
}),
|
||||||
|
path('coverage/<path:path>', serve, {
|
||||||
|
'document_root': coverage_dir,
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
|
||||||
|
handler404 = "thrillwiki.views.handler404"
|
||||||
|
handler500 = "thrillwiki.views.handler500"
|
||||||
|
|||||||
190
uv.lock
generated
190
uv.lock
generated
@@ -1,5 +1,4 @@
|
|||||||
version = 1
|
version = 1
|
||||||
revision = 1
|
|
||||||
requires-python = ">=3.13"
|
requires-python = ">=3.13"
|
||||||
|
|
||||||
[[package]]
|
[[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 },
|
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]black-24.10.0-py3-none-any.whl", hash = "sha256:[AWS-SECRET-REMOVED]9853e47a294a3dd963c1dd7d", size = 206898 },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "bleach"
|
|
||||||
version = "6.2.0"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "webencodings" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]bleach-6.2.0.tar.gz", hash = "sha256:[AWS-SECRET-REMOVED]b1317a7e1ba69b56e95f991f", size = 203083 }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]bleach-6.2.0-py3-none-any.whl", hash = "sha256:[AWS-SECRET-REMOVED]36f554da4e432fdd63f31e5e", size = 163406 },
|
|
||||||
]
|
|
||||||
|
|
||||||
[package.optional-dependencies]
|
|
||||||
css = [
|
|
||||||
{ name = "tinycss2" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "certifi"
|
name = "certifi"
|
||||||
version = "2024.12.14"
|
version = "2024.12.14"
|
||||||
@@ -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 }
|
sdist = { url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]django_allauth-65.3.0.tar.gz", hash = "sha256:[AWS-SECRET-REMOVED]a86d873a8a9fd8f0ec57bbbf", size = 1546784 }
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "django-classy-tags"
|
|
||||||
version = "4.1.0"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "django" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]django-classy-tags-4.1.0.tar.gz", hash = "sha256:[AWS-SECRET-REMOVED]bb750b2490a17b161774ee59", size = 24692 }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]django_classy_tags-4.1.0-py3-none-any.whl", hash = "sha256:[AWS-SECRET-REMOVED]a160a847ff449588d4e01e55", size = 14095 },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "django-cleanup"
|
name = "django-cleanup"
|
||||||
version = "9.0.0"
|
version = "9.0.0"
|
||||||
@@ -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 },
|
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]django_htmx-1.21.0-py3-none-any.whl", hash = "sha256:[AWS-SECRET-REMOVED]e7ccd2963495e69afbdb6abe", size = 6901 },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "django-htmx-autocomplete"
|
|
||||||
version = "1.0.5"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "django" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]django_htmx_autocomplete-1.0.5.tar.gz", hash = "sha256:[AWS-SECRET-REMOVED]17bcac3ff0b70766e354ad80", size = 41127 }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]django_htmx_autocomplete-1.0.5-py3-none-any.whl", hash = "sha256:[AWS-SECRET-REMOVED]3572e8742fe5dfa848298735", size = 52127 },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "django-js-asset"
|
|
||||||
version = "3.0.1"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "django" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]django_js_asset-3.0.1.tar.gz", hash = "sha256:[AWS-SECRET-REMOVED]2f6a6bfe93577dee793dc378", size = 7701 }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]django_js_asset-3.0.1-py3-none-any.whl", hash = "sha256:[AWS-SECRET-REMOVED]43c282cb64fe6c13d7ca4c10", size = 4283 },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "django-mptt"
|
|
||||||
version = "0.16.0"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "django-js-asset" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]django_mptt-0.16.0.tar.gz", hash = "sha256:[AWS-SECRET-REMOVED]999b10903b09de62bee84c8e", size = 69886 }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]django_mptt-0.16.0-py3-none-any.whl", hash = "sha256:[AWS-SECRET-REMOVED]5f8472b690dbaf737d2af3b5", size = 115934 },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "django-nyt"
|
|
||||||
version = "1.4.1"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "django" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]django_nyt-1.4.1.tar.gz", hash = "sha256:[AWS-SECRET-REMOVED]1d987ee81bf6a0ac3352b4a1", size = 28960 }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]django_nyt-1.4.1-py3-none-any.whl", hash = "sha256:[AWS-SECRET-REMOVED]47cdb2cc10e7c4f2fecd6aff", size = 41084 },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "django-oauth-toolkit"
|
name = "django-oauth-toolkit"
|
||||||
version = "3.0.1"
|
version = "3.0.1"
|
||||||
@@ -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 },
|
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]django_pgtrigger-4.13.3-py3-none-any.whl", hash = "sha256:[AWS-SECRET-REMOVED]275d86ad756b90c307df3ca4", size = 34059 },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "django-sekizai"
|
|
||||||
version = "4.1.0"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "django" },
|
|
||||||
{ name = "django-classy-tags" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]django-sekizai-4.1.0.tar.gz", hash = "sha256:[AWS-SECRET-REMOVED]3145bff11e58622fc653cdad", size = 14591 }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]django_sekizai-4.1.0-py3-none-any.whl", hash = "sha256:[AWS-SECRET-REMOVED]9a1304a9b9e8b191229e2e4a", size = 8597 },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "django-simple-history"
|
name = "django-simple-history"
|
||||||
version = "3.7.0"
|
version = "3.7.0"
|
||||||
@@ -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 },
|
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]jwcrypto-1.5.6-py3-none-any.whl", hash = "sha256:[AWS-SECRET-REMOVED]8be71f67f03566692fd55789", size = 92520 },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "markdown"
|
|
||||||
version = "3.6"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]Markdown-3.6.tar.gz", hash = "sha256:[AWS-SECRET-REMOVED]a16cb35fa8ed8c2ddfad0224", size = 354715 }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]Markdown-3.6-py3-none-any.whl", hash = "sha256:[AWS-SECRET-REMOVED]d40aa410fbc3b4ee832c850f", size = 105381 },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mccabe"
|
name = "mccabe"
|
||||||
version = "0.7.0"
|
version = "0.7.0"
|
||||||
@@ -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 },
|
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:[AWS-SECRET-REMOVED]44f25406ffaebd50bd98dacb", size = 22997 },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pymdown-extensions"
|
|
||||||
version = "10.5"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "markdown" },
|
|
||||||
{ name = "pyyaml" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]pymdown_extensions-10.5.tar.gz", hash = "sha256:[AWS-SECRET-REMOVED]a294de1989f29d20096cfd0b", size = 788318 }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]pymdown_extensions-10.5-py3-none-any.whl", hash = "sha256:[AWS-SECRET-REMOVED]6ac4c5eb01e27464b80fe879", size = 241130 },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pyopenssl"
|
name = "pyopenssl"
|
||||||
version = "24.3.0"
|
version = "24.3.0"
|
||||||
@@ -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 },
|
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]python_slugify-8.0.4-py2.py3-none-any.whl", hash = "sha256:[AWS-SECRET-REMOVED]5fd9e3a70164fc8c50faa6b8", size = 10051 },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pyyaml"
|
|
||||||
version = "6.0.2"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]pyyaml-6.0.2.tar.gz", hash = "sha256:[AWS-SECRET-REMOVED]e591037abe114850ff7bbc3e", size = 130631 }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:[AWS-SECRET-REMOVED]c3170801852d752aa7a783ba", size = 181309 },
|
|
||||||
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:[AWS-SECRET-REMOVED]997de9efef88badc3bb9e2d1", size = 171679 },
|
|
||||||
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:[AWS-SECRET-REMOVED]0eef8c8f44e0254ab3b07133", size = 733428 },
|
|
||||||
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:[AWS-SECRET-REMOVED]73d41e99c4fff6b6c3276484", size = 763361 },
|
|
||||||
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:[AWS-SECRET-REMOVED]f1e08d9b561cb41b845f69d5", size = 759523 },
|
|
||||||
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:[AWS-SECRET-REMOVED]34e29c2a514c2c0c5fe971cc", size = 726660 },
|
|
||||||
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:[AWS-SECRET-REMOVED]899c72eacb5a668902e4d652", size = 751597 },
|
|
||||||
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:[AWS-SECRET-REMOVED]36abab80d4681424b84c1183", size = 140527 },
|
|
||||||
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:[AWS-SECRET-REMOVED]dd57cdeb95f3f2e085687563", size = 156446 },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "redis"
|
name = "redis"
|
||||||
version = "5.2.1"
|
version = "5.2.1"
|
||||||
@@ -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 },
|
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:[AWS-SECRET-REMOVED]56c89140852d1120324e8686", size = 9755 },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "sorl-thumbnail"
|
|
||||||
version = "12.11.0"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]sorl_thumbnail-12.11.0.tar.gz", hash = "sha256:[AWS-SECRET-REMOVED]f439b2e17b938b91eea463b3", size = 667102 }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]sorl_thumbnail-12.11.0-py3-none-any.whl", hash = "sha256:[AWS-SECRET-REMOVED]4af5e9dc3f31cb605df765b5", size = 42789 },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sqlparse"
|
name = "sqlparse"
|
||||||
version = "0.5.3"
|
version = "0.5.3"
|
||||||
@@ -1051,9 +912,6 @@ dependencies = [
|
|||||||
{ name = "django-cors-headers" },
|
{ name = "django-cors-headers" },
|
||||||
{ name = "django-filter" },
|
{ name = "django-filter" },
|
||||||
{ name = "django-htmx" },
|
{ name = "django-htmx" },
|
||||||
{ name = "django-htmx-autocomplete" },
|
|
||||||
{ name = "django-mptt" },
|
|
||||||
{ name = "django-nyt" },
|
|
||||||
{ name = "django-oauth-toolkit" },
|
{ name = "django-oauth-toolkit" },
|
||||||
{ name = "django-pghistory" },
|
{ name = "django-pghistory" },
|
||||||
{ name = "django-simple-history" },
|
{ name = "django-simple-history" },
|
||||||
@@ -1071,9 +929,7 @@ dependencies = [
|
|||||||
{ name = "pytest-playwright" },
|
{ name = "pytest-playwright" },
|
||||||
{ name = "python-dotenv" },
|
{ name = "python-dotenv" },
|
||||||
{ name = "requests" },
|
{ name = "requests" },
|
||||||
{ name = "sorl-thumbnail" },
|
|
||||||
{ name = "whitenoise" },
|
{ name = "whitenoise" },
|
||||||
{ name = "wiki" },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
@@ -1090,9 +946,6 @@ requires-dist = [
|
|||||||
{ name = "django-cors-headers", specifier = ">=4.3.1" },
|
{ name = "django-cors-headers", specifier = ">=4.3.1" },
|
||||||
{ name = "django-filter", specifier = ">=23.5" },
|
{ name = "django-filter", specifier = ">=23.5" },
|
||||||
{ name = "django-htmx", specifier = ">=1.17.2" },
|
{ name = "django-htmx", specifier = ">=1.17.2" },
|
||||||
{ name = "django-htmx-autocomplete", specifier = ">=1.0.5" },
|
|
||||||
{ name = "django-mptt", specifier = ">=0.16.0" },
|
|
||||||
{ name = "django-nyt", specifier = ">=1.4.1" },
|
|
||||||
{ name = "django-oauth-toolkit", specifier = ">=3.0.1" },
|
{ name = "django-oauth-toolkit", specifier = ">=3.0.1" },
|
||||||
{ name = "django-pghistory", specifier = ">=3.5.2" },
|
{ name = "django-pghistory", specifier = ">=3.5.2" },
|
||||||
{ name = "django-simple-history", specifier = ">=3.5.0" },
|
{ name = "django-simple-history", specifier = ">=3.5.0" },
|
||||||
@@ -1110,21 +963,7 @@ requires-dist = [
|
|||||||
{ name = "pytest-playwright", specifier = ">=0.4.3" },
|
{ name = "pytest-playwright", specifier = ">=0.4.3" },
|
||||||
{ name = "python-dotenv", specifier = ">=1.0.1" },
|
{ name = "python-dotenv", specifier = ">=1.0.1" },
|
||||||
{ name = "requests", specifier = ">=2.32.3" },
|
{ name = "requests", specifier = ">=2.32.3" },
|
||||||
{ name = "sorl-thumbnail", specifier = ">=12.11.0" },
|
|
||||||
{ name = "whitenoise", specifier = ">=6.6.0" },
|
{ name = "whitenoise", specifier = ">=6.6.0" },
|
||||||
{ name = "wiki", specifier = ">=0.11.2" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "tinycss2"
|
|
||||||
version = "1.4.0"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "webencodings" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]tinycss2-1.4.0.tar.gz", hash = "sha256:[AWS-SECRET-REMOVED]c1ae10ebccdea16fb404a9b7", size = 87085 }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]tinycss2-1.4.0-py3-none-any.whl", hash = "sha256:[AWS-SECRET-REMOVED]4bb905a5775adb0d884c5289", size = 26610 },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -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 },
|
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]urllib3-2.3.0-py3-none-any.whl", hash = "sha256:[AWS-SECRET-REMOVED]710050facf0dd6911440e3df", size = 128369 },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "webencodings"
|
|
||||||
version = "0.5.1"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]webencodings-0.5.1.tar.gz", hash = "sha256:[AWS-SECRET-REMOVED]865afcc4aab16748587e1923", size = 9721 }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:[AWS-SECRET-REMOVED]3f95be16fc9acd2947514a78", size = 11774 },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "whitenoise"
|
name = "whitenoise"
|
||||||
version = "6.8.2"
|
version = "6.8.2"
|
||||||
@@ -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 },
|
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]whitenoise-6.8.2-py3-none-any.whl", hash = "sha256:[AWS-SECRET-REMOVED]ab9726e5772ac50fb45d2280", size = 20158 },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "wiki"
|
|
||||||
version = "0.11.2"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "bleach", extra = ["css"] },
|
|
||||||
{ name = "django" },
|
|
||||||
{ name = "django-mptt" },
|
|
||||||
{ name = "django-nyt" },
|
|
||||||
{ name = "django-sekizai" },
|
|
||||||
{ name = "markdown" },
|
|
||||||
{ name = "pillow" },
|
|
||||||
{ name = "pymdown-extensions" },
|
|
||||||
{ name = "sorl-thumbnail" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]wiki-0.11.2.tar.gz", hash = "sha256:[AWS-SECRET-REMOVED]4140b4c9c64497736c1594d7", size = 2274191 }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]wiki-0.11.2-py3-none-any.whl", hash = "sha256:[AWS-SECRET-REMOVED]a1182bd105f2cbfebbeb20aa", size = 2436316 },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zope-interface"
|
name = "zope-interface"
|
||||||
version = "7.2"
|
version = "7.2"
|
||||||
|
|||||||
@@ -1 +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