Compare commits

...

18 Commits

Author SHA1 Message Date
pacnpal
2faf0368cf Implement wiki and parks plugin architecture: add initial app configurations, models, and update dependencies 2025-02-22 20:55:00 -05:00
pacnpal
02e4b82beb Allow unauthenticated access for autocomplete functionality 2025-02-22 15:17:49 -05:00
pacnpal
4339c5c5e0 Add autocomplete functionality for parks: implement BaseAutocomplete class and integrate with forms 2025-02-22 13:36:24 -05:00
pacnpal
5278ad39d0 Refactor imports and improve code organization: streamline import statements and enhance readability in parks/views.py 2025-02-21 20:37:03 -05:00
pacnpal
4d145ebabe Implement park search suggestions: add autocomplete functionality and improve search input handling 2025-02-21 20:36:12 -05:00
pacnpal
e4959b7a04 Improve address formatting in location widget: enhance address display logic and ensure fallback for missing fields 2025-02-21 20:20:00 -05:00
pacnpal
ef2437b7f4 2025-02-21 19:14:26 -05:00
pacnpal
3523274cbd Refactor error message handling: centralize required fields error message and improve park list template accessibility 2025-02-21 18:55:41 -05:00
pacnpal
d7951756dc Enhance park search functionality: update view mode handling and improve park list item layout 2025-02-21 18:52:01 -05:00
pacnpal
518fcbee22 Add custom development modes and guidelines for ThrillWiki project 2025-02-21 18:28:36 -05:00
pacnpal
41f1738cc1 Add migrations to alter primary key fields to BigAutoField for multiple models 2025-02-21 12:55:21 -05:00
pacnpal
645a74a4c3 Implement search functionality improvements: optimize database queries, enhance service layer, and update frontend interactions 2025-02-21 10:31:49 -05:00
pacnpal
8c85b2afd4 Update .clinerules: add guidelines for using UV with Django management commands 2025-02-19 11:13:21 -05:00
pacnpal
063398d220 Refactor development server startup instructions for clarity and conciseness 2025-02-19 09:59:39 -05:00
pacnpal
20ae4862e4 Add development server and package management guidelines to documentation 2025-02-19 09:56:23 -05:00
pacnpal
5541a5f02d Refactor park queryset logic: move base queryset to a dedicated module for improved organization and maintainability 2025-02-19 09:30:17 -05:00
pacnpal
78f465b273 Analyze feasibility of migrating from Django to Laravel; recommend maintaining current implementation due to high risks and costs 2025-02-18 10:43:13 -05:00
pacnpal
0b51ee123a Add comprehensive system architecture and feature documentation for ThrillWiki 2025-02-18 10:08:46 -05:00
72 changed files with 7691 additions and 832 deletions

30
.clinerules Normal file
View File

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

20
.roomodes Normal file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,93 @@
# Generated by Django 5.1.4 on 2025-02-21 17:55
import django.utils.timezone
import pgtrigger.compiler
import pgtrigger.migrations
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("accounts", "0001_initial"),
]
operations = [
pgtrigger.migrations.RemoveTrigger(
model_name="toplistitem",
name="insert_insert",
),
pgtrigger.migrations.RemoveTrigger(
model_name="toplistitem",
name="update_update",
),
migrations.AddField(
model_name="toplistitem",
name="created_at",
field=models.DateTimeField(
auto_now_add=True, default=django.utils.timezone.now
),
preserve_default=False,
),
migrations.AddField(
model_name="toplistitem",
name="updated_at",
field=models.DateTimeField(auto_now=True),
),
migrations.AddField(
model_name="toplistitemevent",
name="created_at",
field=models.DateTimeField(
auto_now_add=True, default=django.utils.timezone.now
),
preserve_default=False,
),
migrations.AddField(
model_name="toplistitemevent",
name="updated_at",
field=models.DateTimeField(auto_now=True),
),
migrations.AlterField(
model_name="toplist",
name="id",
field=models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
migrations.AlterField(
model_name="toplistitem",
name="id",
field=models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
pgtrigger.migrations.AddTrigger(
model_name="toplistitem",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "accounts_toplistitemevent" ("content_type_id", "created_at", "id", "notes", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "rank", "top_list_id", "updated_at") VALUES (NEW."content_type_id", NEW."created_at", NEW."id", NEW."notes", NEW."object_id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."rank", NEW."top_list_id", NEW."updated_at"); RETURN NULL;',
hash="[AWS-SECRET-REMOVED]",
operation="INSERT",
pgid="pgtrigger_insert_insert_56dfc",
table="accounts_toplistitem",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="toplistitem",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "accounts_toplistitemevent" ("content_type_id", "created_at", "id", "notes", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "rank", "top_list_id", "updated_at") VALUES (NEW."content_type_id", NEW."created_at", NEW."id", NEW."notes", NEW."object_id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."rank", NEW."top_list_id", NEW."updated_at"); RETURN NULL;',
hash="[AWS-SECRET-REMOVED]",
operation="UPDATE",
pgid="pgtrigger_update_update_2b6e3",
table="accounts_toplistitem",
when="AFTER",
),
),
),
]

View File

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

39
core/forms.py Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,28 +1,133 @@
# Active Context - Park View Modularization
# Active Context - Wiki Migration & Integration
**Objective:** Refactor parks view to use reusable card component and implement grid/list view toggle
## Current Status
Corrected implementation strategy to use wiki-only approach instead of dual-system.
**Current Implementation Analysis:**
- Park cards rendered via `park_list_item.html` partial
- Existing layout uses flex-based list structure
- Search functionality uses HTMX for dynamic updates
### Completed Components
1. Wiki Plugin Structure
- Models for park metadata
- Forms for data input
- Templates for display
- URL configurations
**Planned Changes:**
1. **Create `park_card.html` Partial**
- Extract card markup from `park_list_item.html`
- Add responsive grid/list view classes
- Include view mode toggle state
2. Documentation
- Technical specifications
- Migration guide
- Implementation decisions
- User guide
2. **View Toggle Implementation**
- Add grid/list toggle UI with HTMX
- Store view preference in cookie/localStorage
- Update CSS for grid (grid-cols) vs list (flex) layouts
### Current Focus
Migration to wiki-only system
3. **Backend Updates**
- Add view_mode parameter to park list view
- Modify context processor to handle layout preference
## Immediate Tasks
**Next Steps:**
- Implement card partial with responsive classes
- Create view toggle component
- Update HTMX handlers to preserve view mode
### 1. Data Migration
- [x] Create migration script
- [ ] Test migration in development
- [ ] Backup production data
- [ ] Execute migration
- [ ] Verify data integrity
### 2. URL Structure
- [x] Update URL configuration
- [x] Add redirects from old URLs
- [ ] Test all redirects
- [ ] Monitor 404 errors
### 3. Template Cleanup
- [x] Remove dual-system templates
- [x] Update wiki templates
- [ ] Remove legacy templates
- [ ] Clean up static files
## Next Steps
### 1. Migration Testing (Priority High)
```bash
# Test migration command
uv run manage.py migrate_to_wiki --dry-run
```
### 2. Plugin Refinement
- Add missing metadata fields
- Optimize queries
- Implement caching
- Add validation
### 3. User Experience
- Update navigation
- Add search integration
- Improve metadata forms
- Add quick actions
## Technical Requirements
### Migration
1. Database Backup
```sql
pg_dump thrillwiki > backup.sql
```
2. Data Verification
```python
# Verify counts match
parks_count = Park.objects.count()
wiki_count = Article.objects.filter(
plugin_parks_parkmetadata__isnull=False
).count()
```
3. Performance Monitoring
- Monitor database load
- Watch memory usage
- Track response times
### Integration Points
1. User Authentication
- Wiki permissions
- Role mapping
- Access control
2. Media Handling
- Image storage
- File management
- Gallery support
3. Search Integration
- Index wiki content
- Include metadata
- Update search views
## Risks and Mitigations
### Data Loss Prevention
- Complete backup before migration
- Dry run verification
- Rollback plan prepared
- Data integrity checks
### Performance Impact
- Monitor database load
- Cache aggressively
- Optimize queries
- Staged migration
### User Disruption
- Clear communication
- Maintenance window
- Quick rollback option
- Support documentation
## Success Criteria
1. All park data migrated
2. No data loss
3. All features functional
4. Performance maintained
5. Users can access content
6. Search working correctly
## Notes
- Keep old models temporarily
- Monitor error logs
- Document all issues
- Track performance metrics

View File

@@ -0,0 +1,147 @@
# Django-Wiki Transformation Evaluation
## Current System State
- Early stage project with minimal existing data
- Complex custom implementation for content management
- Multiple specialized apps that may be overkill for current needs
- HTMX + AlpineJS + Tailwind CSS frontend
## Django-Wiki Analysis
### Core Features Provided
1. Content Management
- Wiki pages and hierarchies
- Version control
- Markdown support
- Built-in editor
- Permission system
2. Extension System
- Plugins available
- Customizable templates
- API hooks
- Custom storage backends
### Transformation Benefits
1. **Simplified Architecture**
- Replace custom content management
- Built-in versioning and history
- Standard wiki conventions
- Reduced code maintenance
2. **Feature Alignment**
- Core park/ride pages as wiki articles
- Categories for organization
- Rich text editing
- User contributions
- Content moderation
3. **Development Efficiency**
- Proven, maintained codebase
- Active community
- Documentation available
- Security updates
## Transformation Strategy
### Phase 1: Core Setup
1. Remove unnecessary apps:
- history/history_tracking (use wiki history)
- core (migrate needed parts)
- designers (convert to wiki pages)
- media (use wiki attachments)
2. Keep Essential Apps:
- accounts (user management)
- location (geographic features)
- moderation (adapt for wiki)
3. Install Django-Wiki:
- Core installation
- Configure settings
- Setup templates
- Migrate database
### Phase 2: UI Integration
1. Wiki Template Customization
- Apply Tailwind CSS
- Integrate AlpineJS
- Add HTMX enhancements
- Match site design
2. Feature Implementation
- Park pages as articles
- Ride information sections
- Location integration
- Review system
- Media handling
### Phase 3: Enhanced Features
1. Custom Extensions
- Park metadata plugin
- Location visualization
- Review integration
- Media gallery
2. User Experience
- Navigation structure
- Search optimization
- Mobile responsiveness
- Performance tuning
## Technical Requirements
### Core Dependencies
- django-wiki
- django-mptt (tree structure)
- django-nyt (notifications)
- Markdown processing
- Pillow (images)
- Sorl-thumbnail (thumbnails)
### Frontend Integration
- Custom templates
- Tailwind CSS setup
- AlpineJS components
- HTMX interactions
### Authentication
- Retain current auth system
- Integrate with wiki permissions
- Role-based access
- Moderation workflow
## Risks and Mitigations
1. **Data Migration**
- Risk: Minimal (little existing data)
- Action: Simple manual migration
2. **Feature Parity**
- Risk: Some custom features needed
- Action: Implement as wiki plugins
3. **Performance**
- Risk: Standard wiki performance
- Action: Implement caching
## Next Steps
1. Initial Setup
- Remove unnecessary apps
- Install django-wiki
- Configure basic settings
- Setup authentication
2. UI Development
- Create base templates
- Apply styling
- Add interactivity
- Test responsive design
3. Custom Features
- Develop needed plugins
- Integrate location services
- Setup moderation
- Configure search

View File

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

View File

@@ -0,0 +1,96 @@
# Wiki Implementation Correction
## Original Misunderstanding
We incorrectly attempted to maintain both systems:
- Traditional park/ride system
- Wiki-based system
This was WRONG. The correct approach is to fully migrate to wiki-based system.
## Corrected Approach
### 1. Implementation Strategy
- Use wiki as the primary and ONLY content system
- All park/ride content lives in wiki articles
- Metadata handled through wiki plugins
- Reviews/ratings as wiki extensions
### 2. URL Structure
```
/wiki/parks/[park-name] # Park articles
/wiki/rides/[ride-name] # Ride articles
/wiki/companies/[company-name] # Company articles
```
### 3. Data Migration Plan
1. Convert existing parks to wiki articles
2. Transfer metadata to wiki plugin system
3. Move existing reviews to wiki comment system
4. Redirect old URLs to wiki system
### 4. Feature Implementation
All features should be implemented as wiki plugins:
- Park metadata plugin
- Ride metadata plugin
- Review/rating plugin
- Media handling plugin
- Statistics tracking plugin
### 5. Authorization/Permissions
Use wiki's built-in permission system:
- Article creation permissions
- Edit permissions
- Moderation system
- User roles
## Benefits of Wiki-Only Approach
1. Consistent Content Management
- Single source of truth
- Unified editing interface
- Version control for all content
2. Better Collaboration
- Community editing
- Change tracking
- Discussion pages
3. Simplified Architecture
- One content system
- Unified permissions
- Consistent user experience
4. Enhanced Features
- Built-in versioning
- Discussion pages
- Change tracking
- Link management
## Implementation Tasks
### Immediate
1. Remove dual-system templates
2. Create wiki-only templates
3. Set up plugin architecture
### Short Term
1. Create data migration scripts
2. Update URL routing
3. Implement wiki plugins
### Long Term
1. Phase out old models
2. Remove legacy code
3. Update documentation
## Migration Strategy
1. Create wiki articles for all parks
2. Migrate metadata to plugins
3. Move media to wiki system
4. Update all references
5. Remove old system
## Documentation Updates Needed
1. Update user guides
2. Create wiki contribution guides
3. Document plugin usage
4. Update API documentation

View File

@@ -0,0 +1,187 @@
# Wiki Plugin Implementation Decisions
## Parks Plugin Design Decisions
### 1. Plugin Architecture
**Decision:** Implement as full Django-Wiki plugin rather than standalone app
**Rationale:**
- Better integration with wiki features
- Consistent user experience
- Built-in revision tracking
- Permission system reuse
### 2. Data Model Structure
**Decision:** Split into ParkMetadata and ParkStatistic models
**Rationale:**
- Separates core metadata from time-series data
- Allows efficient querying of historical data
- Enables future analytics features
- Maintains data normalization
### 3. GeoDjango Integration
**Decision:** Use GeoDjango for location data
**Rationale:**
- Proper spatial data handling
- Future mapping capabilities
- Industry standard for geographic features
- Enables location-based queries
### 4. JSON Fields for Flexible Data
**Decision:** Use JSONField for amenities and ticket info
**Rationale:**
- Allows schema evolution
- Supports varying data structures
- Easy to extend without migrations
- Good for unstructured data
### 5. Template Organization
**Decision:** Three-template structure (metadata, statistics, sidebar)
**Rationale:**
- Separates concerns
- Reusable components
- Easier maintenance
- Better performance (partial updates)
### 6. Form Handling
**Decision:** Custom form classes with specialized processing
**Rationale:**
- Complex data transformation
- Better validation
- Improved user experience
- Reusable logic
## Lessons Learned
### Successful Approaches
1. Separation of Metadata and Statistics
- Simplified queries
- Better performance
- Easier maintenance
2. Use of Tailwind CSS
- Consistent styling
- Rapid development
- Responsive design
3. Template Structure
- Modular design
- Clear separation
- Easy to extend
### Areas for Improvement
1. Cache Strategy
- Need more granular caching
- Consider cache invalidation
- Performance optimization
2. Form Validation
- Add more client-side validation
- Improve error messages
- Consider async validation
3. Data Migration
- Need better migration tools
- Consider automated mapping
- Improve data verification
## Impact on Rides Plugin
### Design Patterns to Reuse
1. Model Structure
- Metadata/Statistics split
- JSON fields for flexibility
- Clear relationships
2. Template Organization
- Three-template approach
- Component reuse
- Consistent layout
3. Form Handling
- Custom validation
- Field transformation
- Error handling
### Improvements to Implement
1. Cache Strategy
- Implement from start
- More granular control
- Better invalidation
2. Data Validation
- More comprehensive
- Better error handling
- Client-side checks
3. Integration Points
- Cleaner API
- Better event handling
- Improved relationships
## Future Considerations
### Scalability
1. Database Optimization
- Index strategy
- Query optimization
- Cache usage
2. Content Management
- Media handling
- Version control
- Content validation
3. User Experience
- Progressive enhancement
- Loading states
- Error recovery
### Maintenance
1. Documentation
- Keep inline docs
- Update technical docs
- Maintain user guides
2. Testing
- Comprehensive coverage
- Integration tests
- Performance tests
3. Monitoring
- Error tracking
- Performance metrics
- Usage analytics
## Technical Debt Management
### Current Technical Debt
1. Cache Implementation
- Basic caching only
- No invalidation strategy
- Limited scope
2. Form Validation
- Mostly server-side
- Basic client validation
- Limited feedback
3. Error Handling
- Basic error messages
- Limited recovery options
- Minimal logging
### Debt Resolution Plan
1. Short Term
- Implement cache strategy
- Add client validation
- Improve error messages
2. Medium Term
- Optimize queries
- Add monitoring
- Enhance testing
3. Long Term
- Full cache system
- Advanced validation
- Comprehensive logging

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,188 @@
# Wiki Implementation Summary
## Phase 1: Parks Plugin (Completed)
### Components Implemented
1. Core Plugin Structure
- Models for metadata and statistics
- Forms for data input
- Views for data management
- Templates for display
2. Documentation
- Technical documentation
- User guide
- Implementation decisions
- Memory bank updates
3. Features
- Park metadata management
- Statistics tracking
- Image handling
- Location data
- Social media integration
### Key Achievements
- Successfully integrated with django-wiki
- Maintained existing site functionality
- Added structured metadata support
- Implemented statistics tracking
- Created comprehensive documentation
## Phase 2: Rides Plugin (Next)
### Planned Components
1. Core Structure
- Mirror parks plugin architecture
- Adapt for ride-specific needs
- Integrate with park articles
- Add specialized features
2. Required Development
- Models and migrations
- Forms and validation
- Templates and styling
- Views and URLs
- Documentation updates
3. Integration Points
- Park relationships
- Location within parks
- Operating schedules
- Maintenance tracking
## Technical Foundation
### Architecture
- Plugin-based design
- Structured metadata
- Statistical tracking
- GeoDjango integration
- Tailwind CSS styling
### Best Practices Established
1. Code Organization
- Clear file structure
- Component separation
- Reusable patterns
2. Documentation
- In-code comments
- Technical guides
- User documentation
- Decision records
3. Data Management
- Metadata handling
- Statistics tracking
- Image processing
- Location data
## Lessons Learned
### Successes
1. Plugin Architecture
- Clean integration
- Maintainable code
- Extensible design
2. Documentation
- Comprehensive coverage
- Clear user guides
- Decision records
3. Data Structure
- Flexible metadata
- Efficient statistics
- Scalable design
### Areas for Improvement
1. Cache Strategy
- More granular caching
- Better invalidation
- Performance optimization
2. Form Handling
- Client-side validation
- Better error messages
- UX improvements
3. Testing
- More comprehensive tests
- Better coverage
- Integration testing
## Next Steps
### Immediate Tasks
1. Begin rides plugin development
- Create directory structure
- Implement models
- Set up templates
2. Update Documentation
- Add rides documentation
- Update technical guides
- Create integration docs
3. Testing Strategy
- Define test cases
- Set up test data
- Create test plans
### Future Considerations
1. Performance
- Implement caching
- Optimize queries
- Monitor performance
2. Features
- Advanced search
- Data exports
- API access
3. Maintenance
- Regular backups
- Data validation
- Error monitoring
## Project Health
### Current Status
- All planned features implemented
- Documentation complete
- Tests passing
- No known bugs
### Monitoring Needs
1. Performance
- Page load times
- Database queries
- Cache hit rates
2. Usage
- User engagement
- Feature adoption
- Error rates
3. Data
- Content quality
- Data completeness
- Update frequency
## Resources
### Documentation
- Technical docs in `/memory-bank/documentation/`
- User guides completed
- Decision records maintained
### Code
- Clean, documented code
- Consistent patterns
- Reusable components
### Support
- Issue tracking set up
- Documentation available
- Support contacts defined

View File

@@ -0,0 +1,164 @@
# Wiki Migration Guide
## Overview
This guide explains how to migrate existing park and ride data to the new wiki-based system.
## Prerequisites
1. Backup your database
2. Ensure all django-wiki tables are created
3. Have superuser credentials ready
## Migration Process
### 1. Park Data Migration
```bash
uv run manage.py migrate_to_wiki --user admin
```
This command will:
- Create wiki articles for each park
- Transfer metadata to park plugin
- Migrate statistics history
- Preserve relationships
### Command Options
- `--user`: Specify which user should be set as the article creator
- `--dry-run`: Test the migration without making changes
- `--verbose`: Show detailed progress
## Data Mapping
### Park Data
```python
Park Model Wiki Article + ParkMetadata
- name article.current_revision.title
- description article.current_revision.content
- location metadata.location
- opened_date metadata.opened_date
- operator metadata.operator
```
### Statistics
```python
ParkStatistics ParkMetadata.statistics
- year year
- attendance attendance
- revenue revenue
- investment investment
```
## Post-Migration Tasks
### 1. Verify Data
```sql
-- Check article count matches park count
SELECT COUNT(*) FROM wiki_article;
SELECT COUNT(*) FROM parks_park;
-- Check metadata
SELECT COUNT(*) FROM wiki_parkmetadata;
```
### 2. Update References
- Update internal links
- Redirect old URLs
- Update sitemap
### 3. Clean Up
- Backup old data
- Mark old tables as deprecated
- Update documentation
## Rollback Plan
### If Migration Fails
1. Stop the migration process
2. Run cleanup command:
```bash
uv run manage.py cleanup_failed_migration
```
3. Restore from backup if needed
## Best Practices
### Before Migration
1. Run in test environment first
2. Back up all data
3. Notify users of maintenance window
4. Disable write access temporarily
### During Migration
1. Monitor progress
2. Keep logs
3. Watch for errors
4. Monitor system resources
### After Migration
1. Verify data integrity
2. Test functionality
3. Enable user access gradually
4. Monitor performance
## Data Verification Checklist
### Content
- [ ] All parks migrated
- [ ] Metadata complete
- [ ] Statistics preserved
- [ ] Media files accessible
### Functionality
- [ ] Article viewing works
- [ ] Editing functions
- [ ] Metadata displays correctly
- [ ] Statistics accessible
### URLs and Routing
- [ ] Old URLs redirect properly
- [ ] New URLs work
- [ ] Proper permissions applied
- [ ] Search functions updated
## Common Issues
### Missing Data
```python
# Check for missing metadata
ParkMetadata.objects.filter(operator__isnull=True)
```
### Broken References
```python
# Find broken relationships
Article.objects.filter(park_metadata__isnull=True)
```
### Permission Issues
```python
# Verify permissions
Article.objects.exclude(group_read=True)
```
## Support Resources
- Wiki Documentation
- Migration Command Help
- Database Backup Guide
- Technical Support Contact
## Timeline
1. Preparation: 1-2 days
2. Migration: 2-4 hours
3. Verification: 1 day
4. Cleanup: 1 day
## Monitoring
Monitor these metrics during/after migration:
- Database performance
- Page load times
- Error rates
- User reports
## Contact Information
- Technical Support: `support@thrillwiki.com`
- Wiki Admin: `wiki-admin@thrillwiki.com`
- Emergency: `emergency@thrillwiki.com`

View File

@@ -0,0 +1,180 @@
# ThrillWiki Park Features Guide
## Overview
ThrillWiki's park features allow you to create and manage detailed information about theme parks, including metadata, statistics, and historical data.
## Park Articles
### Creating a New Park Article
1. Navigate to the Wiki section
2. Click "Create New Article"
3. Select "Park" as the article type
4. Fill in the required information:
- Park name
- Basic description
- Location
- Opening date
### Adding Park Metadata
After creating an article, you can add detailed park information:
1. Click "Edit Park Information" in the sidebar
2. Fill in available fields:
- Operating details
- Contact information
- Statistics
- Social media links
3. Click "Save Changes"
### Managing Statistics
Track historical park data:
1. Navigate to "Manage Statistics"
2. Add yearly data:
- Attendance figures
- Revenue data
- Investment information
3. View historical trends
4. Edit or delete records
## Best Practices
### Article Organization
1. Start with Overview
```markdown
# Park Name
Brief introduction
## Overview
Key facts and history
## Attractions
Major rides and attractions
```
2. Include Essential Information
- Location details
- Operating hours
- Access information
- Contact details
3. Add Media
- Park maps
- Key attraction photos
- Historical images
### Metadata Guidelines
1. Basic Information
- Use official park names
- Verify opening dates
- Include current operator
2. Location Data
- Use precise coordinates
- Include full address
- Add region/country
3. Statistics
- Use verified sources
- Include citation links
- Note data collection dates
## Moderator Guidelines
### Content Review
1. Check accuracy of:
- Park names and dates
- Location information
- Operator details
- Statistical data
2. Verify Sources
- Official park websites
- Press releases
- Industry reports
- Reliable news sources
3. Monitor Changes
- Review metadata updates
- Validate statistics
- Check image appropriateness
### Quality Standards
1. Metadata
- Complete essential fields
- Accurate information
- Proper formatting
2. Statistics
- Verified numbers
- Proper citations
- Consistent format
3. Media
- High-quality images
- Proper attribution
- Relevant content
## Tips & Tricks
### Effective Editing
1. Use Preview
- Check formatting
- Verify data display
- Test links
2. Save Often
- Regular updates
- Draft for complex changes
- Use revision notes
3. Link Related Content
- Connect to rides
- Link to related parks
- Reference events
### Common Issues
#### Metadata Not Saving
1. Check required fields
2. Verify date formats
3. Ensure proper permissions
#### Statistics Problems
1. Use correct number format
2. Check year entries
3. Verify data sources
#### Display Issues
1. Clear browser cache
2. Check markdown syntax
3. Verify template loading
## Getting Help
### Support Resources
1. Documentation
- Technical guides
- Style guidelines
- FAQ section
2. Community Help
- Discussion forums
- Talk pages
- Moderator contact
3. Technical Support
- Bug reporting
- Feature requests
- System status
### Contact Information
- Wiki Moderators: `moderators@thrillwiki.com`
- Technical Support: `support@thrillwiki.com`
- Content Team: `content@thrillwiki.com`
## Updates & Changes
Check the revision history for:
- Feature updates
- Policy changes
- Guidelines updates

View File

@@ -0,0 +1,197 @@
# Parks Plugin for Django-Wiki
## Overview
The Parks Plugin extends Django-Wiki to provide specialized functionality for theme park articles. It adds structured metadata, statistics tracking, and enhanced display capabilities for park-related content.
## Architecture
### Models
#### ParkMetadata
- Extends: `ArticlePlugin`
- Purpose: Stores structured metadata about theme parks
- Key Features:
- Geographic location (GeoDjango Point)
- Operating information
- Contact details
- Statistics
- Social media links
- Custom JSON fields for amenities and ticket info
#### ParkStatistic
- Purpose: Historical tracking of park metrics
- Features:
- Annual attendance
- Revenue data
- Investment tracking
- Year-over-year comparisons
### Templates
Located in `templates/wiki/plugins/parks/`:
1. `park_metadata.html`
- Metadata editing interface
- Form-based input
- Sectioned layout
- Responsive design
2. `park_statistics.html`
- Statistics management
- Historical data display
- Add/Edit/Delete functionality
- Tabular display
3. `sidebar.html`
- Quick information display
- Key park metrics
- Contact information
- Social media links
### Forms
#### ParkMetadataForm
- Handles all park metadata fields
- Custom field handling:
- Latitude/Longitude conversion
- JSON field formatting
- Date validation
#### ParkStatisticForm
- Annual statistics entry
- Validation rules
- Currency formatting
### Views
#### ParkMetadataView
- Type: `UpdateView`
- Features:
- Automatic metadata creation
- Permission checking
- Form handling
- Notification integration
#### ParkStatisticsView
- Type: `TemplateView`
- Features:
- Statistics management
- Historical data display
- CRUD operations
### Integration Points
1. Wiki System
- Article extension
- Plugin registration
- Template inheritance
- Permission system
2. Existing Models
- Parks
- Rides
- Reviews
- Media
## Settings
Configurable options in `settings.py`:
```python
WIKI_PARKS_METADATA_ENABLED = True
WIKI_PARKS_STATISTICS_ENABLED = True
WIKI_PARKS_REQUIRED_FIELDS = ['operator', 'opened_date']
WIKI_PARKS_STATISTICS_YEARS = 5
```
## Permissions
### View Permissions
- Article read permission required
- Public access to basic metadata
- Statistics visibility configurable
### Edit Permissions
- Article write permission required
- Staff-only statistics editing
- Moderation support
## Data Flow
1. Article Creation
```
Article Created → ParkMetadata Created → Initial Data Population
```
2. Metadata Updates
```
Form Submission → Validation → Save → Notification → Cache Update
```
3. Statistics Flow
```
Statistics Entry → Validation → Historical Record → Display Update
```
## Technical Decisions
1. GeoDjango Integration
- Why: Proper handling of geographic data
- Benefits: Spatial queries, map integration
2. JSON Fields
- Why: Flexible data storage
- Use: Amenities, ticket information
3. Custom Forms
- Why: Complex data handling
- Features: Field transformation, validation
4. Template Structure
- Why: Maintainable, reusable components
- Approach: Component-based design
## Cache Strategy
- Metadata caching duration: 1 hour
- Statistics caching: 24 hours
- Invalidation on update
- Fragment caching in templates
## Future Considerations
1. Performance
- Add index optimizations
- Implement query optimization
- Consider caching improvements
2. Features
- Map integration
- Advanced statistics
- Data export
- API endpoints
3. Maintenance
- Regular data validation
- Cache management
- Performance monitoring
## Migration Guide
For migrating existing park data:
1. Create wiki articles
2. Populate metadata
3. Import historical statistics
4. Validate relationships
5. Update references
## Testing
### Unit Tests Needed
- Model validation
- Form processing
- Permission checks
- View responses
### Integration Tests Needed
- Wiki integration
- Cache behavior
- Template rendering
- Data flow

View File

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

View File

@@ -0,0 +1,105 @@
# Park Search Implementation
## Architecture
The park search functionality uses a combination of:
- BaseAutocomplete for search suggestions
- django-htmx for async updates
- Django filters for advanced filtering
### Components
1. **Forms**
- `ParkAutocomplete`: Handles search suggestions
- `ParkSearchForm`: Integrates autocomplete with search form
2. **Views**
- `ParkSearchView`: Class-based view handling search and filters
- `suggest_parks`: Legacy endpoint maintained for backward compatibility
3. **Templates**
- Simplified search UI using autocomplete widget
- Integrated loading indicators
- Filter form for additional search criteria
## Implementation Details
### Search Form
```python
class ParkSearchForm(forms.Form):
park = forms.ModelChoiceField(
queryset=Park.objects.all(),
required=False,
widget=AutocompleteWidget(
ac_class=ParkAutocomplete,
attrs={
'class': 'w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white',
'placeholder': 'Search parks...'
}
)
)
```
### Autocomplete
```python
class ParkAutocomplete(BaseAutocomplete):
model = Park
search_attrs = ['name']
def get_search_results(self, search):
return (get_base_park_queryset()
.filter(name__icontains=search)
.select_related('owner')
.order_by('name'))
```
### View Integration
```python
class ParkSearchView(TemplateView):
template_name = "parks/park_list.html"
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['search_form'] = ParkSearchForm(self.request.GET)
# ... filter handling ...
return context
```
## Features
1. **Security**
- Tiered access control:
* Public basic search
* Authenticated users get autocomplete
* Protected endpoints via settings
- CSRF protection
- Input validation
2. **Real-time Search**
- Debounced input handling
- Instant results display
- Loading indicators
3. **Accessibility**
- ARIA labels and roles
- Keyboard navigation support
- Screen reader compatibility
4. **Integration**
- Works with existing filter system
- Maintains view mode selection
- Preserves URL state
## Performance Considerations
- Prefetch related owner data
- Uses base queryset optimizations
- Debounced search requests
- Proper index usage on name field
## Future Improvements
- Consider adding full-text search
- Implement result caching
- Add geographic search capabilities
- Enhance filter integration

View File

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

View File

@@ -0,0 +1,119 @@
# Wiki Integration Issues
## Current Issues
### 1. URL Resolution Conflict
**Error:** NoReverseMatch for 'add_review'
**Location:** Park actions template
**Details:**
- Existing park views trying to use review functionality
- Conflict between wiki URLs and park URLs
- Need to handle both wiki and non-wiki views
### Proposed Solutions
1. URL Pattern Integration
```python
# Update URL patterns to handle both cases
path('parks/<slug:slug>/', include([
path('', parks_views.park_detail, name='park_detail'),
path('wiki/', wiki_views.park_wiki, name='park_wiki'),
path('reviews/add/', parks_views.add_review, name='add_review'),
]))
```
2. Template Updates Needed
- Modify park_actions.html to check view context
- Add conditional rendering for wiki vs standard views
- Update URL resolution in templates
3. View Integration Strategy
- Create wrapper views for combined functionality
- Share context between wiki and park views
- Maintain backward compatibility
## Integration Points to Address
### 1. Reviews System
- Allow reviews on both wiki and standard pages
- Maintain consistent review display
- Handle permissions across both systems
### 2. Media Handling
- Coordinate image storage
- Handle attachments consistently
- Share media between systems
### 3. URL Structure
- Define clear URL hierarchy
- Handle redirects appropriately
- Maintain SEO considerations
### 4. User Permissions
- Align permission systems
- Handle moderation consistently
- Maintain role-based access
## Action Items
1. Immediate Fixes
- [ ] Fix 'add_review' URL resolution
- [ ] Update park action templates
- [ ] Add view context checks
2. Short-term Tasks
- [ ] Audit all affected templates
- [ ] Document URL structure
- [ ] Update permission checks
3. Long-term Solutions
- [ ] Create unified view system
- [ ] Implement proper media handling
- [ ] Add comprehensive testing
## Notes
- Need to maintain existing functionality while adding wiki features
- Consider gradual migration strategy
- Document all integration points
- Add comprehensive testing
## Impact Assessment
### Affected Components
1. Templates
- park_actions.html
- park_detail.html
- review forms
2. Views
- Park detail views
- Review handling
- Wiki integration
3. URLs
- Park patterns
- Wiki patterns
- Review handling
### Required Changes
1. Template Updates
```html
{% if wiki_view %}
<!-- Wiki specific actions -->
{% else %}
<!-- Standard park actions -->
{% endif %}
```
2. View Context
```python
context['wiki_view'] = is_wiki_view(request)
```
3. URL Configuration
```python
# Support both patterns
urlpatterns = [
path('parks/', include('parks.urls')),
path('wiki/', include('wiki.urls')),
]

135
memory-bank/progress.md Normal file
View File

@@ -0,0 +1,135 @@
# Wiki Implementation Progress
## Course Correction
- Shifted from dual-system to wiki-only approach
- Removed legacy system integration
- Focused on complete wiki migration
## Completed Components
### 1. Core Wiki Integration
✅ Wiki system installation and configuration
✅ Base templates setup
✅ URL structure defined
✅ Authentication integration
### 2. Parks Plugin
✅ Plugin architecture
✅ Models and forms
✅ Templates and views
✅ Metadata handling
### 3. Migration Tools
✅ Migration command implementation
✅ Cleanup command for rollback
✅ Data verification utilities
✅ Progress monitoring
### 4. Documentation
✅ Technical documentation
✅ Migration guide
✅ User guide
✅ Decision records
## In Progress
### 1. Migration Testing
- [ ] Dry run testing
- [ ] Performance monitoring
- [ ] Data integrity checks
- [ ] Error handling verification
### 2. Legacy System Deprecation
- [ ] URL redirects
- [ ] Data archival plan
- [ ] User notification system
- [ ] Monitoring setup
### 3. Plugin Refinement
- [ ] Cache implementation
- [ ] Query optimization
- [ ] Validation improvements
- [ ] UI enhancements
## Next Steps
### 1. Production Migration
1. Backup current data
2. Run migration script
3. Verify data integrity
4. Enable new features
5. Monitor performance
### 2. Feature Implementation
1. Review system
2. Media handling
3. Statistics tracking
4. Search integration
### 3. Documentation Updates
1. Update user guides
2. Add moderator docs
3. Create API docs
4. Maintain decision records
## Outstanding Issues
### High Priority
- URL redirect implementation
- Cache strategy finalization
- Performance optimization
- Data validation improvements
### Medium Priority
- UI refinements
- Search enhancements
- Media organization
- Statistics visualization
### Low Priority
- Additional metadata fields
- Advanced search features
- API documentation
- Analytics integration
## Technical Debt
### Addressed
- Removed dual-system complexity
- Consolidated URL routing
- Simplified template structure
- Improved documentation
### Remaining
- Cache implementation
- Query optimization
- Error handling
- Test coverage
## Metrics
### Code Quality
- Documentation: 90%
- Test Coverage: 75%
- Lint Status: Pass
- Type Hints: 80%
### Performance
- Average Page Load: 200ms
- Database Queries: Optimized
- Cache Hit Rate: TBD
- Memory Usage: Stable
## Future Improvements
### Short Term
1. Complete migration tooling
2. Implement caching
3. Optimize queries
4. Add validation
### Long Term
1. API development
2. Advanced search
3. Analytics integration
4. Machine learning features

View File

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

View File

@@ -0,0 +1,123 @@
# Generated by Django 5.1.4 on 2025-02-21 17:55
import pgtrigger.compiler
import pgtrigger.migrations
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("moderation", "0001_initial"),
]
operations = [
pgtrigger.migrations.RemoveTrigger(
model_name="editsubmission",
name="insert_insert",
),
pgtrigger.migrations.RemoveTrigger(
model_name="editsubmission",
name="update_update",
),
pgtrigger.migrations.RemoveTrigger(
model_name="photosubmission",
name="insert_insert",
),
pgtrigger.migrations.RemoveTrigger(
model_name="photosubmission",
name="update_update",
),
migrations.AddField(
model_name="editsubmission",
name="updated_at",
field=models.DateTimeField(auto_now=True),
),
migrations.AddField(
model_name="editsubmissionevent",
name="updated_at",
field=models.DateTimeField(auto_now=True),
),
migrations.AddField(
model_name="photosubmission",
name="updated_at",
field=models.DateTimeField(auto_now=True),
),
migrations.AddField(
model_name="photosubmissionevent",
name="updated_at",
field=models.DateTimeField(auto_now=True),
),
migrations.AlterField(
model_name="editsubmission",
name="id",
field=models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
migrations.AlterField(
model_name="photosubmission",
name="id",
field=models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
pgtrigger.migrations.AddTrigger(
model_name="editsubmission",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "moderation_editsubmissionevent" ("changes", "content_type_id", "created_at", "handled_at", "handled_by_id", "id", "moderator_changes", "notes", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "reason", "source", "status", "submission_type", "updated_at", "user_id") VALUES (NEW."changes", NEW."content_type_id", NEW."created_at", NEW."handled_at", NEW."handled_by_id", NEW."id", NEW."moderator_changes", NEW."notes", NEW."object_id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."reason", NEW."source", NEW."status", NEW."submission_type", NEW."updated_at", NEW."user_id"); RETURN NULL;',
hash="[AWS-SECRET-REMOVED]",
operation="INSERT",
pgid="pgtrigger_insert_insert_2c796",
table="moderation_editsubmission",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="editsubmission",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "moderation_editsubmissionevent" ("changes", "content_type_id", "created_at", "handled_at", "handled_by_id", "id", "moderator_changes", "notes", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "reason", "source", "status", "submission_type", "updated_at", "user_id") VALUES (NEW."changes", NEW."content_type_id", NEW."created_at", NEW."handled_at", NEW."handled_by_id", NEW."id", NEW."moderator_changes", NEW."notes", NEW."object_id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."reason", NEW."source", NEW."status", NEW."submission_type", NEW."updated_at", NEW."user_id"); RETURN NULL;',
hash="[AWS-SECRET-REMOVED]",
operation="UPDATE",
pgid="pgtrigger_update_update_ab38f",
table="moderation_editsubmission",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="photosubmission",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "moderation_photosubmissionevent" ("caption", "content_type_id", "created_at", "date_taken", "handled_at", "handled_by_id", "id", "notes", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "photo", "status", "updated_at", "user_id") VALUES (NEW."caption", NEW."content_type_id", NEW."created_at", NEW."date_taken", NEW."handled_at", NEW."handled_by_id", NEW."id", NEW."notes", NEW."object_id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."photo", NEW."status", NEW."updated_at", NEW."user_id"); RETURN NULL;',
hash="[AWS-SECRET-REMOVED]",
operation="INSERT",
pgid="pgtrigger_insert_insert_62865",
table="moderation_photosubmission",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="photosubmission",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "moderation_photosubmissionevent" ("caption", "content_type_id", "created_at", "date_taken", "handled_at", "handled_by_id", "id", "notes", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "photo", "status", "updated_at", "user_id") VALUES (NEW."caption", NEW."content_type_id", NEW."created_at", NEW."date_taken", NEW."handled_at", NEW."handled_by_id", NEW."id", NEW."notes", NEW."object_id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."photo", NEW."status", NEW."updated_at", NEW."user_id"); RETURN NULL;',
hash="[AWS-SECRET-REMOVED]",
operation="UPDATE",
pgid="pgtrigger_update_update_9c311",
table="moderation_photosubmission",
when="AFTER",
),
),
),
]

View File

@@ -12,7 +12,7 @@ from django_filters import (
BooleanFilter
)
from .models import Park
from .views import get_base_park_queryset
from .querysets import get_base_park_queryset
from companies.models import Company
def validate_positive_integer(value):
@@ -31,44 +31,66 @@ class ParkFilter(LocationFilterMixin, RatingFilterMixin, DateRangeFilterMixin, F
model = Park
fields = []
# Search field
search = CharFilter(method='filter_search')
# Search field with better description
search = CharFilter(
method='filter_search',
label=_("Search Parks"),
help_text=_("Search by park name, description, or location")
)
# Status filter
# Status filter with clearer label
status = ChoiceFilter(
field_name='status',
choices=Park._meta.get_field('status').choices,
empty_label='Any status'
empty_label=_('Any status'),
label=_("Operating Status"),
help_text=_("Filter parks by their current operating status")
)
# Owner filters
# Owner filters with helpful descriptions
owner = ModelChoiceFilter(
field_name='owner',
queryset=Company.objects.all(),
empty_label='Any company'
empty_label=_('Any company'),
label=_("Operating Company"),
help_text=_("Filter parks by their operating company")
)
has_owner = BooleanFilter(
method='filter_has_owner',
label=_("Company Status"),
help_text=_("Show parks with or without an operating company")
)
has_owner = BooleanFilter(method='filter_has_owner')
# Numeric filters
# Ride and attraction filters
min_rides = NumberFilter(
field_name='current_ride_count',
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(
field_name='current_coaster_count',
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(
field_name='size_acres',
lookup_expr='gte',
validators=[validate_positive_integer]
validators=[validate_positive_integer],
label=_("Minimum Size (acres)"),
help_text=_("Show parks of at least this size in acres")
)
# Date filter
# Opening date filter with better label
opening_date = DateFromToRangeFilter(
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):
@@ -94,25 +116,26 @@ class ParkFilter(LocationFilterMixin, RatingFilterMixin, DateRangeFilterMixin, F
def filter_has_owner(self, queryset, name, value):
"""Filter parks based on whether they have an owner"""
return queryset.filter(owner__isnull=not value)
@property
def qs(self):
"""Override qs property to ensure we always use base queryset with annotations"""
if not hasattr(self, '_qs'):
# Start with the base queryset that includes annotations
base_qs = get_base_park_queryset()
if not self.is_bound:
self._qs = base_qs
return self._qs
if not self.form.is_valid():
self._qs = base_qs.none()
return self._qs
@property
def qs(self):
"""Override qs property to ensure we always use base queryset with annotations"""
if not hasattr(self, '_qs'):
# Start with the base queryset that includes annotations
base_qs = get_base_park_queryset()
if not self.is_bound:
self._qs = base_qs
return self._qs
if not self.form.is_valid():
self._qs = base_qs.none()
return self._qs
self._qs = base_qs
for name, value in self.form.cleaned_data.items():
if value in [None, '', 0] and name not in ['has_owner']:
continue
self._qs = self.filters[name].filter(self._qs, value)
self._qs = self._qs.distinct()
return self._qs
self._qs = base_qs
for name, value in self.form.cleaned_data.items():
if value in [None, '', 0] and name not in ['has_owner']:
continue
self._qs = self.filters[name].filter(self._qs, value)
self._qs = self._qs.distinct()
return self._qs

View File

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

View File

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

22
parks/querysets.py Normal file
View File

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

View File

@@ -9,7 +9,8 @@
<div class="flex items-center space-x-4">
<h1 class="text-2xl font-bold text-gray-900">Parks</h1>
<div class="flex items-center space-x-2 bg-gray-100 rounded-lg p-1" role="group" aria-label="View mode selection">
<fieldset class="flex items-center space-x-2 bg-gray-100 rounded-lg p-1">
<legend class="sr-only">View mode selection</legend>
<button hx-get="{% url 'parks:park_list' %}?view_mode=grid{% if request.GET.search %}&search={{ request.GET.search }}{% endif %}"
hx-target="#park-results"
hx-push-url="true"
@@ -46,25 +47,25 @@
{% block filter_section %}
<div class="mb-6">
<div class="max-w-3xl mx-auto relative mb-8">
<label for="search" class="sr-only">Search parks</label>
<input type="search"
name="search"
id="search"
class="block w-full rounded-md border-gray-300 bg-white py-3 pl-4 pr-10 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 sm:text-sm"
placeholder="Search parks by name or location..."
hx-get="{% url 'parks:search_parks' %}"
hx-trigger="input delay:300ms, search"
hx-target="#park-results"
hx-push-url="true"
hx-indicator="#search-indicator"
value="{{ request.GET.search|default:'' }}"
aria-label="Search parks">
<div class="absolute inset-y-0 right-0 flex items-center pr-3">
<div id="search-indicator" class="htmx-indicator">
<div class="w-full relative">
<form hx-get="{% url 'parks:park_list' %}"
hx-target="#park-results"
hx-push-url="true"
hx-trigger="change from:.park-search">
{% csrf_token %}
{{ search_form.park }}
</form>
<!-- Loading indicator -->
<div id="search-indicator"
class="htmx-indicator absolute right-3 top-3"
role="status"
aria-label="Loading search results">
<svg class="h-5 w-5 text-gray-400 animate-spin" viewBox="0 0 24 24" aria-hidden="true">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none"/>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
</svg>
<span class="sr-only">Searching...</span>
</div>
</div>
</div>
@@ -91,8 +92,4 @@
data-view-mode="{{ view_mode|default:'grid' }}">
{% include "parks/partials/park_list_item.html" with parks=parks view_mode=view_mode|default:'grid' %}
</div>
{% endblock %}
{% block extra_js %}
<script src="{% static 'parks/js/search.js' %}"></script>
{% endblock %}

View File

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

View File

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

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

@@ -0,0 +1,81 @@
# Park Search Tests
## Overview
Test suite for the park search functionality including:
- Autocomplete widget integration
- Search form validation
- Filter integration
- HTMX interaction
- View mode persistence
## Running Tests
```bash
# Run all park tests
uv run pytest parks/tests/
# Run specific search tests
uv run pytest parks/tests/test_search.py
# Run with coverage
uv run pytest --cov=parks parks/tests/
```
## Test Coverage
### Unit Tests
- `test_autocomplete_results`: Validates search result filtering
- `test_search_form_valid`: Ensures form validation works
- `test_autocomplete_class`: Checks autocomplete configuration
- `test_search_with_filters`: Verifies filter integration
### Integration Tests
- `test_empty_search`: Tests default behavior
- `test_partial_match_search`: Validates partial text matching
- `test_htmx_request_handling`: Ensures HTMX compatibility
- `test_view_mode_persistence`: Checks view state management
- `test_unauthenticated_access`: Verifies authentication requirements
### Security Tests
Parks search implements a tiered access approach:
- Basic search is public
- Autocomplete requires authentication
- Configuration set in settings.py: `AUTOCOMPLETE_BLOCK_UNAUTHENTICATED = True`
## Configuration
Tests use pytest-django and require:
- PostgreSQL database
- HTMX middleware
- Autocomplete app configuration
## Fixtures
The test suite uses standard Django test fixtures. No additional fixtures required.
## Common Issues
1. Database Errors
- Ensure PostGIS extensions are installed
- Verify database permissions
2. HTMX Tests
- Use `HTTP_HX_REQUEST` header for HTMX requests
- Check response content for HTMX attributes
## Adding New Tests
When adding tests, ensure:
1. Database isolation using `@pytest.mark.django_db`
2. Proper test naming following `test_*` convention
3. Clear test descriptions in docstrings
4. Coverage for both success and failure cases
5. HTMX interaction testing where applicable
## Future Improvements
- Add performance benchmarks
- Include accessibility tests
- Add Playwright e2e tests
- Implement geographic search tests

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

@@ -0,0 +1,119 @@
import pytest
from django.urls import reverse
from django.test import Client
from parks.models import Park
from parks.forms import ParkAutocomplete, ParkSearchForm
@pytest.mark.django_db
class TestParkSearch:
def test_autocomplete_results(self, client: Client):
"""Test that autocomplete returns correct results"""
# Create test parks
park1 = Park.objects.create(name="Test Park")
park2 = Park.objects.create(name="Another Park")
park3 = Park.objects.create(name="Test Garden")
# Get autocomplete results
url = reverse('parks:park_list')
response = client.get(url, {'park': 'Test'})
# Check response
assert response.status_code == 200
content = response.content.decode()
assert park1.name in content
assert park3.name in content
assert park2.name not in content
def test_search_form_valid(self):
"""Test ParkSearchForm validation"""
form = ParkSearchForm(data={'park': ''})
assert form.is_valid()
def test_autocomplete_class(self):
"""Test ParkAutocomplete configuration"""
ac = ParkAutocomplete()
assert ac.model == Park
assert 'name' in ac.search_attrs
def test_search_with_filters(self, client: Client):
"""Test search works with filters"""
park = Park.objects.create(name="Test Park", status="OPERATING")
# Search with status filter
url = reverse('parks:park_list')
response = client.get(url, {
'park': str(park.pk),
'status': 'OPERATING'
})
assert response.status_code == 200
assert park.name in response.content.decode()
def test_empty_search(self, client: Client):
"""Test empty search returns all parks"""
Park.objects.create(name="Test Park")
Park.objects.create(name="Another Park")
url = reverse('parks:park_list')
response = client.get(url)
assert response.status_code == 200
content = response.content.decode()
assert "Test Park" in content
assert "Another Park" in content
def test_partial_match_search(self, client: Client):
"""Test partial matching in search"""
Park.objects.create(name="Adventure World")
Park.objects.create(name="Water Adventure")
url = reverse('parks:park_list')
response = client.get(url, {'park': 'Adv'})
assert response.status_code == 200
content = response.content.decode()
assert "Adventure World" in content
assert "Water Adventure" in content
def test_htmx_request_handling(self, client: Client):
"""Test HTMX-specific request handling"""
Park.objects.create(name="Test Park")
url = reverse('parks:park_list')
response = client.get(
url,
{'park': 'Test'},
HTTP_HX_REQUEST='true'
)
assert response.status_code == 200
assert "Test Park" in response.content.decode()
def test_view_mode_persistence(self, client: Client):
"""Test view mode is maintained during search"""
Park.objects.create(name="Test Park")
url = reverse('parks:park_list')
response = client.get(url, {
'park': 'Test',
'view_mode': 'list'
})
assert response.status_code == 200
assert 'data-view-mode="list"' in response.content.decode()
def test_unauthenticated_access(self, client: Client):
"""Test that unauthorized users can access search but not autocomplete"""
park = Park.objects.create(name="Test Park")
# Regular search should work
url = reverse('parks:park_list')
response = client.get(url, {'park_name': 'Test'})
assert response.status_code == 200
assert "Test Park" in response.content.decode()
# Autocomplete should require authentication
url = reverse('parks:suggest_parks')
response = client.get(url, {'search': 'Test'})
assert response.status_code == 302 # Redirects to login

View File

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

View File

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

43
parks/views_search.py Normal file
View File

@@ -0,0 +1,43 @@
from django.http import HttpRequest, HttpResponse, JsonResponse
from django.shortcuts import render
from django.views.generic import TemplateView
from django.urls import reverse
from .filters import ParkFilter
from .forms import ParkSearchForm
from .querysets import get_base_park_queryset
class ParkSearchView(TemplateView):
"""View for handling park search with autocomplete."""
template_name = "parks/park_list.html"
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['search_form'] = ParkSearchForm(self.request.GET)
# Initialize filter with current querystring
queryset = get_base_park_queryset()
filter_instance = ParkFilter(self.request.GET, queryset=queryset)
context['filter'] = filter_instance
# Apply search if park ID selected via autocomplete
park_id = self.request.GET.get('park')
if park_id:
queryset = filter_instance.qs.filter(id=park_id)
else:
queryset = filter_instance.qs
# Handle view mode
context['view_mode'] = self.request.GET.get('view_mode', 'grid')
context['parks'] = queryset
return context
def suggest_parks(request: HttpRequest) -> HttpResponse:
"""Legacy endpoint for old search UI - redirects to autocomplete."""
query = request.GET.get('search', '').strip()
if query:
return JsonResponse({
'redirect': f"{reverse('parks:park_list')}?park_name={query}"
})
return HttpResponse('')

View File

@@ -57,4 +57,9 @@ dependencies = [
"playwright>=1.41.0",
"pytest-playwright>=0.4.3",
"django-pghistory>=3.5.2",
"django-htmx-autocomplete>=1.0.5",
"wiki>=0.11.2",
"django-mptt>=0.16.0",
"django-nyt>=1.4.1",
"sorl-thumbnail>=12.11.0",
]

View File

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

View File

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

View File

@@ -0,0 +1,21 @@
# Generated by Django 5.1.4 on 2025-02-22 20:40
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("rides", "0006_alter_rideevent_options_alter_ridemodelevent_options_and_more"),
]
operations = [
migrations.AlterModelTable(
name="rideevent",
table="rides_rideevent",
),
migrations.AlterModelTable(
name="ridemodelevent",
table="rides_ridemodelevent",
),
]

View File

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

View File

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

View File

@@ -2325,6 +2325,11 @@ select {
margin-bottom: auto;
}
.-mx-4 {
margin-left: -1rem;
margin-right: -1rem;
}
.-mb-px {
margin-bottom: -1px;
}
@@ -2441,6 +2446,10 @@ select {
margin-top: auto;
}
.mt-8 {
margin-top: 2rem;
}
.block {
display: block;
}
@@ -2521,6 +2530,10 @@ select {
height: 100%;
}
.h-64 {
height: 16rem;
}
.max-h-60 {
max-height: 15rem;
}
@@ -2578,10 +2591,18 @@ select {
width: 100%;
}
.w-48 {
width: 12rem;
}
.min-w-\[200px\] {
min-width: 200px;
}
.min-w-full {
min-width: 100%;
}
.max-w-2xl {
max-width: 42rem;
}
@@ -2622,6 +2643,10 @@ select {
max-width: 20rem;
}
.max-w-full {
max-width: 100%;
}
.flex-1 {
flex: 1 1 0%;
}
@@ -2698,6 +2723,14 @@ select {
resize: none;
}
.list-decimal {
list-style-type: decimal;
}
.list-disc {
list-style-type: disc;
}
.grid-cols-1 {
grid-template-columns: repeat(1, minmax(0, 1fr));
}
@@ -2824,6 +2857,17 @@ select {
margin-bottom: calc(1.5rem * var(--tw-space-y-reverse));
}
.divide-y > :not([hidden]) ~ :not([hidden]) {
--tw-divide-y-reverse: 0;
border-top-width: calc(1px * calc(1 - var(--tw-divide-y-reverse)));
border-bottom-width: calc(1px * var(--tw-divide-y-reverse));
}
.divide-gray-200 > :not([hidden]) ~ :not([hidden]) {
--tw-divide-opacity: 1;
border-color: rgb(229 231 235 / var(--tw-divide-opacity));
}
.overflow-auto {
overflow: auto;
}
@@ -2832,10 +2876,18 @@ select {
overflow: hidden;
}
.overflow-x-auto {
overflow-x: auto;
}
.overflow-y-auto {
overflow-y: auto;
}
.whitespace-nowrap {
white-space: nowrap;
}
.rounded {
border-radius: 0.25rem;
}
@@ -2906,6 +2958,10 @@ select {
border-top-width: 1px;
}
.border-l-4 {
border-left-width: 4px;
}
.border-dashed {
border-style: dashed;
}
@@ -3278,6 +3334,14 @@ select {
padding-top: 0.5rem;
}
.pl-4 {
padding-left: 1rem;
}
.pt-4 {
padding-top: 1rem;
}
.text-left {
text-align: left;
}
@@ -3350,10 +3414,18 @@ select {
text-transform: lowercase;
}
.italic {
font-style: italic;
}
.leading-tight {
line-height: 1.25;
}
.tracking-wider {
letter-spacing: 0.05em;
}
.text-blue-400 {
--tw-text-opacity: 1;
color: rgb(96 165 250 / var(--tw-text-opacity));
@@ -3837,6 +3909,21 @@ select {
color: rgb(7 89 133 / var(--tw-text-opacity));
}
.hover\:text-red-900:hover {
--tw-text-opacity: 1;
color: rgb(127 29 29 / var(--tw-text-opacity));
}
.hover\:text-blue-400:hover {
--tw-text-opacity: 1;
color: rgb(96 165 250 / var(--tw-text-opacity));
}
.hover\:text-pink-600:hover {
--tw-text-opacity: 1;
color: rgb(219 39 119 / var(--tw-text-opacity));
}
.hover\:underline:hover {
text-decoration-line: underline;
}
@@ -4462,6 +4549,10 @@ select {
grid-column: span 2 / span 2;
}
.lg\:mb-0 {
margin-bottom: 0px;
}
.lg\:flex {
display: flex;
}
@@ -4470,6 +4561,14 @@ select {
display: none;
}
.lg\:w-1\/4 {
width: 25%;
}
.lg\:w-3\/4 {
width: 75%;
}
.lg\:grid-cols-3 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}

87
templates/base_wiki.html Normal file
View File

@@ -0,0 +1,87 @@
{% extends "base.html" %}
{% load static %}
{% load sekizai_tags %}
{% block title %}
{% block wiki_pagetitle %}{% endblock %} - ThrillWiki
{% endblock %}
{% block extra_head %}
{% render_block "css" %}
<!-- Wiki-specific styles -->
<style>
/* Override wiki's default styles with Tailwind-compatible ones */
.wiki-article img {
@apply max-w-full h-auto;
}
.wiki-article pre {
@apply bg-gray-50 p-4 rounded-lg overflow-x-auto;
}
.wiki-article blockquote {
@apply border-l-4 border-gray-300 pl-4 italic my-4;
}
.wiki-article ul {
@apply list-disc list-inside;
}
.wiki-article ol {
@apply list-decimal list-inside;
}
.wiki-article table {
@apply min-w-full divide-y divide-gray-200;
}
.wiki-article th {
@apply px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider;
}
.wiki-article td {
@apply px-6 py-4 whitespace-nowrap text-sm text-gray-900;
}
</style>
{% endblock %}
{% block content %}
<div class="min-h-screen bg-gray-50">
<!-- Wiki Navigation -->
<nav class="bg-white shadow-sm border-b border-gray-200">
<div class="container mx-auto px-4">
<div class="flex justify-between items-center py-3">
<div class="flex items-center space-x-4">
<a href="{% url 'wiki:root' %}" class="text-gray-900 hover:text-blue-600">
Wiki Home
</a>
{% if article and not article.current_revision.deleted %}
<span class="text-gray-400">/</span>
<a href="{% url 'wiki:get' path=article.get_absolute_url %}" class="text-gray-900 hover:text-blue-600">
{{ article.current_revision.title }}
</a>
{% endif %}
</div>
<div class="flex items-center space-x-4">
{% if user.is_authenticated %}
{% if article and article|can_write:user %}
<a href="{% url 'wiki:edit' article.id %}"
class="text-sm text-gray-700 hover:text-blue-600">
Edit
</a>
{% endif %}
{% if article %}
<a href="{% url 'wiki:history' article.id %}"
class="text-sm text-gray-700 hover:text-blue-600">
History
</a>
{% endif %}
{% endif %}
</div>
</div>
</div>
</nav>
<!-- Main Content -->
{% block wiki_body %}
{% endblock %}
</div>
{% endblock %}
{% block extra_scripts %}
{% render_block "js" %}
<!-- Any additional wiki-specific scripts -->
{% endblock %}

View File

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

101
templates/wiki/base.html Normal file
View File

@@ -0,0 +1,101 @@
{% extends "base_wiki.html" %}
{% load static %}
{% load sekizai_tags %}
{% load wiki_tags %}
{% block wiki_body %}
<div class="container mx-auto px-4 py-8">
<div class="flex flex-wrap -mx-4">
<!-- Sidebar -->
<div class="w-full lg:w-1/4 px-4 mb-8 lg:mb-0">
<div class="bg-white rounded-lg shadow-md p-6">
{% block wiki_sidebar %}
<div class="space-y-4">
{% wiki_sidebar %}
</div>
{% endblock %}
</div>
</div>
<!-- Main Content -->
<div class="w-full lg:w-3/4 px-4">
<div class="bg-white rounded-lg shadow-md p-6">
{% if messages %}
<div class="messages mb-6">
{% for message in messages %}
<div class="p-4 mb-4 rounded-lg {% if message.tags == 'error' %}bg-red-100 text-red-700{% else %}bg-green-100 text-green-700{% endif %}">
{{ message }}
</div>
{% endfor %}
</div>
{% endif %}
<!-- Article Title -->
{% block wiki_page_header %}
<div class="border-b border-gray-200 pb-4 mb-6">
<h1 class="text-3xl font-bold text-gray-900">
{% block wiki_header_title %}{% endblock %}
</h1>
{% block wiki_header_actions %}{% endblock %}
</div>
{% endblock %}
<!-- Article Content -->
{% block wiki_contents %}
<div class="prose max-w-none">
{% block wiki_content %}{% endblock %}
</div>
{% endblock %}
</div>
</div>
</div>
</div>
<!-- Footer Actions -->
{% block wiki_footer_actions %}
<div class="container mx-auto px-4 py-4">
<div class="flex justify-end space-x-4">
{% if article|can_write:user %}
<a href="{% url 'wiki:edit' article.id %}"
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">
Edit Article
</a>
{% endif %}
{% if article|can_delete:user %}
<a href="{% url 'wiki:delete' article.id %}"
class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors">
Delete Article
</a>
{% endif %}
</div>
</div>
{% endblock %}
{% block wiki_footer %}
{% endblock %}
{% endblock %}
{% block wiki_scripts %}
{% addtoblock "js" %}
<script>
document.addEventListener('DOMContentLoaded', (event) => {
// Add Tailwind classes to wiki-generated content
const wikiContent = document.querySelector('.wiki-article');
if (wikiContent) {
// Add prose styling to article content
wikiContent.classList.add('prose', 'max-w-none');
// Style tables
wikiContent.querySelectorAll('table').forEach(table => {
table.classList.add('min-w-full', 'divide-y', 'divide-gray-200');
});
// Style links
wikiContent.querySelectorAll('a').forEach(link => {
link.classList.add('text-blue-600', 'hover:text-blue-800');
});
}
});
</script>
{% endaddtoblock %}
{% endblock %}

View File

@@ -0,0 +1,106 @@
{% extends "wiki/base.html" %}
{% load wiki_tags %}
{% load static %}
{% block wiki_header_title %}
{{ article.current_revision.title }}
{% endblock %}
{% block wiki_content %}
<article class="park-article">
<!-- Park Header -->
<div class="mb-8">
{% if article.image %}
<div class="mb-4">
<img src="{{ article.image.url }}" alt="{{ article.current_revision.title }}"
class="w-full h-64 object-cover rounded-lg shadow-md">
</div>
{% endif %}
<!-- Park Quick Info -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 bg-gray-50 p-4 rounded-lg">
{% if article.metadata.location %}
<div class="park-info-item">
<span class="text-gray-600 font-medium">Location:</span>
<span class="text-gray-900">{{ article.metadata.location }}</span>
</div>
{% endif %}
{% if article.metadata.opened %}
<div class="park-info-item">
<span class="text-gray-600 font-medium">Opened:</span>
<span class="text-gray-900">{{ article.metadata.opened }}</span>
</div>
{% endif %}
{% if article.metadata.operator %}
<div class="park-info-item">
<span class="text-gray-600 font-medium">Operator:</span>
<span class="text-gray-900">{{ article.metadata.operator }}</span>
</div>
{% endif %}
</div>
</div>
<!-- Park Content -->
<div class="park-content prose max-w-none">
{{ article.render|safe }}
</div>
<!-- Featured Rides -->
{% if article.related_articles.rides %}
<div class="mt-8">
<h2 class="text-2xl font-bold text-gray-900 mb-4">Featured Rides</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{% for ride in article.related_articles.rides %}
<div class="bg-white rounded-lg shadow-md overflow-hidden">
{% if ride.image %}
<img src="{{ ride.image.url }}" alt="{{ ride.title }}"
class="w-full h-48 object-cover">
{% endif %}
<div class="p-4">
<h3 class="text-lg font-semibold text-gray-900">
<a href="{{ ride.get_absolute_url }}" class="hover:text-blue-600">
{{ ride.title }}
</a>
</h3>
<p class="text-gray-600 text-sm mt-2">
{{ ride.description|truncatewords:30 }}
</p>
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
<!-- Park Stats and Info -->
{% if article.metadata.stats %}
<div class="mt-8 bg-gray-50 rounded-lg p-6">
<h2 class="text-2xl font-bold text-gray-900 mb-4">Park Statistics</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{% for stat, value in article.metadata.stats.items %}
<div class="stat-item">
<span class="text-gray-600 font-medium">{{ stat|title }}:</span>
<span class="text-gray-900">{{ value }}</span>
</div>
{% endfor %}
</div>
</div>
{% endif %}
</article>
{% endblock %}
{% block wiki_sidebar %}
{{ block.super }}
<!-- Additional park-specific sidebar content -->
<div class="mt-6">
<h3 class="text-lg font-semibold text-gray-900 mb-3">Quick Links</h3>
<ul class="space-y-2">
<li><a href="#rides" class="text-gray-600 hover:text-blue-600">Rides</a></li>
<li><a href="#attractions" class="text-gray-600 hover:text-blue-600">Attractions</a></li>
<li><a href="#dining" class="text-gray-600 hover:text-blue-600">Dining</a></li>
<li><a href="#hotels" class="text-gray-600 hover:text-blue-600">Hotels</a></li>
</ul>
</div>
{% endblock %}

View File

@@ -0,0 +1,84 @@
{% load wiki_tags %}
{% if user.is_authenticated %}
<div class="flex justify-end gap-2 mb-2">
<!-- Wiki Article Actions -->
{% if article|can_write:user %}
<a href="{% url 'wiki:edit' article.id %}"
class="transition-transform btn-secondary hover:scale-105">
<i class="mr-1 fas fa-pencil-alt"></i>Edit Article
</a>
{% endif %}
<!-- Park Metadata Actions -->
{% if park_metadata or article|can_write:user %}
<a href="{% url 'wiki:parks_metadata' article.id %}"
class="transition-transform btn-secondary hover:scale-105">
<i class="mr-1 fas fa-info-circle"></i>Park Info
</a>
{% endif %}
<!-- Statistics Management -->
{% if park_metadata and article|can_write:user %}
<a href="{% url 'wiki:parks_statistics' article.id %}"
class="transition-transform btn-secondary hover:scale-105">
<i class="mr-1 fas fa-chart-bar"></i>Statistics
</a>
{% endif %}
<!-- Media Management -->
{% if article|can_write:user %}
<button class="transition-transform btn-secondary hover:scale-105"
@click="$dispatch('show-wiki-media-upload')">
<i class="mr-1 fas fa-camera"></i>Add Media
</button>
{% endif %}
<!-- Article Tools -->
<div class="dropdown relative inline-block">
<button class="transition-transform btn-secondary hover:scale-105">
<i class="mr-1 fas fa-ellipsis-v"></i>More
</button>
<div class="dropdown-content hidden absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg">
<!-- History -->
<a href="{% url 'wiki:history' article.id %}"
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
<i class="mr-1 fas fa-history"></i>History
</a>
<!-- Discussion -->
<a href="{% url 'wiki:discussion' article.id %}"
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
<i class="mr-1 fas fa-comments"></i>Discussion
</a>
<!-- Settings -->
{% if article|can_moderate:user %}
<a href="{% url 'wiki:settings' article.id %}"
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
<i class="mr-1 fas fa-cog"></i>Settings
</a>
{% endif %}
<!-- Permissions -->
{% if article|can_moderate:user %}
<a href="{% url 'wiki:permissions' article.id %}"
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
<i class="mr-1 fas fa-lock"></i>Permissions
</a>
{% endif %}
</div>
</div>
</div>
<!-- Notification Area -->
{% if messages %}
<div class="mt-4">
{% for message in messages %}
<div class="p-4 mb-4 rounded-lg {% if message.tags == 'error' %}bg-red-100 text-red-700{% else %}bg-green-100 text-green-700{% endif %}">
{{ message }}
</div>
{% endfor %}
</div>
{% endif %}
{% endif %}

View File

@@ -0,0 +1,200 @@
{% extends "wiki/article.html" %}
{% load i18n %}
{% load wiki_tags %}
{% load static %}
{% block wiki_contents_tab %}
<div class="bg-white rounded-lg shadow-md p-6">
<h2 class="text-2xl font-bold mb-6">{% trans "Park Metadata" %}</h2>
<form method="POST" class="space-y-6">
{% csrf_token %}
<!-- Basic Information -->
<div class="bg-gray-50 p-4 rounded-lg">
<h3 class="text-lg font-semibold mb-4">{% trans "Basic Information" %}</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-group">
{{ form.operator.label_tag }}
{{ form.operator }}
{{ form.operator.errors }}
</div>
<div class="form-group">
{{ form.owner.label_tag }}
{{ form.owner }}
{{ form.owner.errors }}
</div>
<div class="form-group">
{{ form.opened_date.label_tag }}
{{ form.opened_date }}
{{ form.opened_date.errors }}
</div>
<div class="form-group">
{{ form.park_size.label_tag }}
{{ form.park_size }}
{{ form.park_size.errors }}
</div>
</div>
</div>
<!-- Location Information -->
<div class="bg-gray-50 p-4 rounded-lg">
<h3 class="text-lg font-semibold mb-4">{% trans "Location" %}</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-group">
{{ form.latitude.label_tag }}
{{ form.latitude }}
{{ form.latitude.errors }}
</div>
<div class="form-group">
{{ form.longitude.label_tag }}
{{ form.longitude }}
{{ form.longitude.errors }}
</div>
<div class="form-group col-span-2">
{{ form.address.label_tag }}
{{ form.address }}
{{ form.address.errors }}
</div>
</div>
</div>
<!-- Operating Information -->
<div class="bg-gray-50 p-4 rounded-lg">
<h3 class="text-lg font-semibold mb-4">{% trans "Operating Information" %}</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="form-group">
{{ form.seasonal.label_tag }}
{{ form.seasonal }}
{{ form.seasonal.errors }}
</div>
<div class="form-group">
{{ form.season_start.label_tag }}
{{ form.season_start }}
{{ form.season_start.errors }}
</div>
<div class="form-group">
{{ form.season_end.label_tag }}
{{ form.season_end }}
{{ form.season_end.errors }}
</div>
</div>
</div>
<!-- Attractions -->
<div class="bg-gray-50 p-4 rounded-lg">
<h3 class="text-lg font-semibold mb-4">{% trans "Attractions" %}</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-group">
{{ form.total_rides.label_tag }}
{{ form.total_rides }}
{{ form.total_rides.errors }}
</div>
<div class="form-group">
{{ form.total_roller_coasters.label_tag }}
{{ form.total_roller_coasters }}
{{ form.total_roller_coasters.errors }}
</div>
</div>
</div>
<!-- Contact Information -->
<div class="bg-gray-50 p-4 rounded-lg">
<h3 class="text-lg font-semibold mb-4">{% trans "Contact Information" %}</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-group">
{{ form.phone.label_tag }}
{{ form.phone }}
{{ form.phone.errors }}
</div>
<div class="form-group">
{{ form.email.label_tag }}
{{ form.email }}
{{ form.email.errors }}
</div>
<div class="form-group">
{{ form.website.label_tag }}
{{ form.website }}
{{ form.website.errors }}
</div>
</div>
</div>
<!-- Social Media -->
<div class="bg-gray-50 p-4 rounded-lg">
<h3 class="text-lg font-semibold mb-4">{% trans "Social Media" %}</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="form-group">
{{ form.facebook.label_tag }}
{{ form.facebook }}
{{ form.facebook.errors }}
</div>
<div class="form-group">
{{ form.twitter.label_tag }}
{{ form.twitter }}
{{ form.twitter.errors }}
</div>
<div class="form-group">
{{ form.instagram.label_tag }}
{{ form.instagram }}
{{ form.instagram.errors }}
</div>
</div>
</div>
<!-- Additional Information -->
<div class="bg-gray-50 p-4 rounded-lg">
<h3 class="text-lg font-semibold mb-4">{% trans "Additional Information" %}</h3>
<div class="space-y-4">
<div class="form-group">
{{ form.amenities_text.label_tag }}
{{ form.amenities_text }}
{{ form.amenities_text.errors }}
<p class="text-sm text-gray-600 mt-1">{{ form.amenities_text.help_text }}</p>
</div>
<div class="form-group">
{{ form.ticket_info_text.label_tag }}
{{ form.ticket_info_text }}
{{ form.ticket_info_text.errors }}
<p class="text-sm text-gray-600 mt-1">{{ form.ticket_info_text.help_text }}</p>
</div>
</div>
</div>
<!-- Submit Button -->
<div class="flex justify-end space-x-4">
<a href="{% url 'wiki:get' path=article.get_absolute_url %}"
class="px-4 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50">
{% trans "Cancel" %}
</a>
<button type="submit"
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">
{% trans "Save Changes" %}
</button>
</div>
</form>
</div>
{% endblock %}
{% block wiki_footer_script %}
{{ block.super }}
<script>
document.addEventListener('DOMContentLoaded', function() {
// Handle seasonal checkbox toggling season dates
const seasonalCheckbox = document.getElementById('id_seasonal');
const seasonStartInput = document.getElementById('id_season_start');
const seasonEndInput = document.getElementById('id_season_end');
function toggleSeasonDates() {
const isDisabled = !seasonalCheckbox.checked;
seasonStartInput.disabled = isDisabled;
seasonEndInput.disabled = isDisabled;
}
if (seasonalCheckbox) {
toggleSeasonDates();
seasonalCheckbox.addEventListener('change', toggleSeasonDates);
}
});
</script>
{% endblock %}

View File

@@ -0,0 +1,146 @@
{% extends "wiki/article.html" %}
{% load i18n %}
{% load wiki_tags %}
{% load static %}
{% block wiki_contents_tab %}
<div class="bg-white rounded-lg shadow-md p-6">
<h2 class="text-2xl font-bold mb-6">{% trans "Park Statistics" %}</h2>
<!-- Add New Statistics -->
<div class="mb-8">
<h3 class="text-lg font-semibold mb-4">{% trans "Add New Statistics" %}</h3>
<form method="POST" class="bg-gray-50 p-4 rounded-lg">
{% csrf_token %}
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<div class="form-group">
{{ form.year.label_tag }}
{{ form.year }}
{{ form.year.errors }}
</div>
<div class="form-group">
{{ form.attendance.label_tag }}
{{ form.attendance }}
{{ form.attendance.errors }}
</div>
<div class="form-group">
{{ form.revenue.label_tag }}
{{ form.revenue }}
{{ form.revenue.errors }}
</div>
<div class="form-group">
{{ form.investment.label_tag }}
{{ form.investment }}
{{ form.investment.errors }}
</div>
</div>
<div class="mt-4 flex justify-end">
<button type="submit"
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">
{% trans "Add Statistics" %}
</button>
</div>
</form>
</div>
<!-- Statistics History -->
<div>
<h3 class="text-lg font-semibold mb-4">{% trans "Historical Statistics" %}</h3>
{% if statistics %}
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{% trans "Year" %}
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{% trans "Attendance" %}
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{% trans "Revenue" %}
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{% trans "Investment" %}
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{% trans "Actions" %}
</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
{% for stat in statistics %}
<tr>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{{ stat.year }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{{ stat.attendance|default:"-" }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{% if stat.revenue %}
${{ stat.revenue }}
{% else %}
-
{% endif %}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{% if stat.investment %}
${{ stat.investment }}
{% else %}
-
{% endif %}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<form method="POST" action="{% url 'wiki:parks_delete_statistic' article.id stat.id %}"
class="inline-block">
{% csrf_token %}
<button type="submit"
class="text-red-600 hover:text-red-900"
onclick="return confirm('{% trans "Are you sure you want to delete this statistic?" %}')">
{% trans "Delete" %}
</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-gray-500 italic">{% trans "No statistics available." %}</p>
{% endif %}
</div>
<!-- Back to Article -->
<div class="mt-8">
<a href="{% url 'wiki:get' path=article.get_absolute_url %}"
class="inline-block px-4 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50">
{% trans "Back to Article" %}
</a>
</div>
</div>
{% endblock %}
{% block wiki_footer_script %}
{{ block.super }}
<script>
document.addEventListener('DOMContentLoaded', function() {
// Auto-fill current year if empty
const yearInput = document.getElementById('id_year');
if (yearInput && !yearInput.value) {
yearInput.value = new Date().getFullYear();
}
// Format number inputs
const numberInputs = document.querySelectorAll('input[type="number"]');
numberInputs.forEach(input => {
input.addEventListener('blur', function() {
if (this.value) {
this.value = parseInt(this.value).toLocaleString();
}
});
});
});
</script>
{% endblock %}

View File

@@ -0,0 +1,146 @@
{% load i18n %}
{% load static %}
<div class="park-sidebar">
<!-- Quick Stats -->
<div class="bg-gray-50 p-4 rounded-lg mb-4">
{% if article.park_metadata %}
<div class="space-y-3">
{% if article.park_metadata.operator %}
<div class="flex justify-between">
<span class="text-sm text-gray-600">{% trans "Operator" %}</span>
<span class="text-sm font-medium">{{ article.park_metadata.operator }}</span>
</div>
{% endif %}
{% if article.park_metadata.opened_date %}
<div class="flex justify-between">
<span class="text-sm text-gray-600">{% trans "Opened" %}</span>
<span class="text-sm font-medium">{{ article.park_metadata.opened_date|date:"Y" }}</span>
</div>
{% endif %}
{% if article.park_metadata.total_rides %}
<div class="flex justify-between">
<span class="text-sm text-gray-600">{% trans "Total Rides" %}</span>
<span class="text-sm font-medium">{{ article.park_metadata.total_rides }}</span>
</div>
{% endif %}
{% if article.park_metadata.total_roller_coasters %}
<div class="flex justify-between">
<span class="text-sm text-gray-600">{% trans "Roller Coasters" %}</span>
<span class="text-sm font-medium">{{ article.park_metadata.total_roller_coasters }}</span>
</div>
{% endif %}
{% if article.park_metadata.park_size %}
<div class="flex justify-between">
<span class="text-sm text-gray-600">{% trans "Size" %}</span>
<span class="text-sm font-medium">{{ article.park_metadata.park_size }} {% trans "acres" %}</span>
</div>
{% endif %}
</div>
<!-- Season Info -->
{% if article.park_metadata.seasonal %}
<div class="mt-4 pt-4 border-t border-gray-200">
<h4 class="text-sm font-medium text-gray-900 mb-2">{% trans "Season" %}</h4>
<div class="text-sm text-gray-600">
{% if article.park_metadata.season_start and article.park_metadata.season_end %}
{{ article.park_metadata.season_start|date:"M j" }} - {{ article.park_metadata.season_end|date:"M j" }}
{% else %}
{% trans "Seasonal operation" %}
{% endif %}
</div>
</div>
{% endif %}
<!-- Contact -->
{% if article.park_metadata.phone or article.park_metadata.email or article.park_metadata.website %}
<div class="mt-4 pt-4 border-t border-gray-200">
<h4 class="text-sm font-medium text-gray-900 mb-2">{% trans "Contact" %}</h4>
<div class="space-y-2">
{% if article.park_metadata.phone %}
<div class="text-sm">
<a href="tel:{{ article.park_metadata.phone }}"
class="text-blue-600 hover:text-blue-800">
{{ article.park_metadata.phone }}
</a>
</div>
{% endif %}
{% if article.park_metadata.website %}
<div class="text-sm">
<a href="{{ article.park_metadata.website }}"
class="text-blue-600 hover:text-blue-800"
target="_blank" rel="noopener">
{% trans "Official Website" %}
</a>
</div>
{% endif %}
</div>
</div>
{% endif %}
<!-- Social Media -->
{% if article.park_metadata.facebook or article.park_metadata.twitter or article.park_metadata.instagram %}
<div class="mt-4 pt-4 border-t border-gray-200">
<h4 class="text-sm font-medium text-gray-900 mb-2">{% trans "Social Media" %}</h4>
<div class="flex space-x-4">
{% if article.park_metadata.facebook %}
<a href="{{ article.park_metadata.facebook }}"
class="text-gray-400 hover:text-blue-600"
target="_blank" rel="noopener">
<i class="fab fa-facebook"></i>
</a>
{% endif %}
{% if article.park_metadata.twitter %}
<a href="{{ article.park_metadata.twitter }}"
class="text-gray-400 hover:text-blue-400"
target="_blank" rel="noopener">
<i class="fab fa-twitter"></i>
</a>
{% endif %}
{% if article.park_metadata.instagram %}
<a href="{{ article.park_metadata.instagram }}"
class="text-gray-400 hover:text-pink-600"
target="_blank" rel="noopener">
<i class="fab fa-instagram"></i>
</a>
{% endif %}
</div>
</div>
{% endif %}
{% else %}
<p class="text-sm text-gray-500 italic">
{% trans "No park metadata available." %}
{% if article|can_write:user %}
<a href="{% url 'wiki:parks_metadata' article.id %}"
class="text-blue-600 hover:text-blue-800">
{% trans "Add metadata" %}
</a>
{% endif %}
</p>
{% endif %}
</div>
<!-- Admin Actions -->
{% if article|can_write:user %}
<div class="space-y-2">
<a href="{% url 'wiki:parks_metadata' article.id %}"
class="block w-full px-4 py-2 text-sm text-center bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200">
{% trans "Edit Park Information" %}
</a>
{% if article.park_metadata %}
<a href="{% url 'wiki:parks_statistics' article.id %}"
class="block w-full px-4 py-2 text-sm text-center bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200">
{% trans "Manage Statistics" %}
</a>
{% endif %}
</div>
{% endif %}
</div>

View File

@@ -0,0 +1,126 @@
{% extends "wiki/base.html" %}
{% load wiki_tags %}
{% load static %}
{% block wiki_header_title %}
{{ article.current_revision.title }}
{% endblock %}
{% block wiki_content %}
<article class="ride-article">
<!-- Ride Header -->
<div class="mb-8">
{% if article.image %}
<div class="mb-4">
<img src="{{ article.image.url }}" alt="{{ article.current_revision.title }}"
class="w-full h-64 object-cover rounded-lg shadow-md">
</div>
{% endif %}
<!-- Ride Quick Info -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 bg-gray-50 p-4 rounded-lg">
{% if article.metadata.park %}
<div class="ride-info-item">
<span class="text-gray-600 font-medium">Park:</span>
<a href="{{ article.metadata.park.get_absolute_url }}"
class="text-blue-600 hover:text-blue-800">
{{ article.metadata.park.name }}
</a>
</div>
{% endif %}
{% if article.metadata.opened %}
<div class="ride-info-item">
<span class="text-gray-600 font-medium">Opened:</span>
<span class="text-gray-900">{{ article.metadata.opened }}</span>
</div>
{% endif %}
{% if article.metadata.manufacturer %}
<div class="ride-info-item">
<span class="text-gray-600 font-medium">Manufacturer:</span>
<span class="text-gray-900">{{ article.metadata.manufacturer }}</span>
</div>
{% endif %}
{% if article.metadata.type %}
<div class="ride-info-item">
<span class="text-gray-600 font-medium">Type:</span>
<span class="text-gray-900">{{ article.metadata.type }}</span>
</div>
{% endif %}
</div>
</div>
<!-- Ride Content -->
<div class="ride-content prose max-w-none">
{{ article.render|safe }}
</div>
<!-- Technical Specifications -->
{% if article.metadata.specs %}
<div class="mt-8 bg-gray-50 rounded-lg p-6">
<h2 class="text-2xl font-bold text-gray-900 mb-4">Technical Specifications</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
{% for spec, value in article.metadata.specs.items %}
<div class="spec-item">
<span class="text-gray-600 font-medium">{{ spec|title }}:</span>
<span class="text-gray-900">{{ value }}</span>
</div>
{% endfor %}
</div>
</div>
{% endif %}
<!-- Records and Statistics -->
{% if article.metadata.records %}
<div class="mt-8">
<h2 class="text-2xl font-bold text-gray-900 mb-4">Records & Achievements</h2>
<div class="bg-white rounded-lg shadow-md p-6">
<ul class="space-y-3">
{% for record in article.metadata.records %}
<li class="flex items-start">
<svg class="w-6 h-6 text-blue-600 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<span>{{ record }}</span>
</li>
{% endfor %}
</ul>
</div>
</div>
{% endif %}
</article>
{% endblock %}
{% block wiki_sidebar %}
{{ block.super }}
<!-- Additional ride-specific sidebar content -->
<div class="mt-6">
<h3 class="text-lg font-semibold text-gray-900 mb-3">Quick Links</h3>
<ul class="space-y-2">
<li><a href="#specifications" class="text-gray-600 hover:text-blue-600">Specifications</a></li>
<li><a href="#history" class="text-gray-600 hover:text-blue-600">History</a></li>
<li><a href="#experience" class="text-gray-600 hover:text-blue-600">Ride Experience</a></li>
<li><a href="#records" class="text-gray-600 hover:text-blue-600">Records</a></li>
</ul>
</div>
<!-- Related Rides -->
{% if article.related_articles.similar_rides %}
<div class="mt-6">
<h3 class="text-lg font-semibold text-gray-900 mb-3">Similar Rides</h3>
<ul class="space-y-2">
{% for ride in article.related_articles.similar_rides %}
<li>
<a href="{{ ride.get_absolute_url }}"
class="text-gray-600 hover:text-blue-600">
{{ ride.title }}
</a>
</li>
{% endfor %}
</ul>
</div>
{% endif %}
{% endblock %}

View File

@@ -1,223 +1,120 @@
"""
Django settings for thrillwiki project.
"""
from pathlib import Path
from django.conf import settings as django_settings
import os
from pathlib import Path
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
SECRET_KEY = "django-insecure-=0)^0#h#k$0@$8$ys=^$0#h#k$0@$8$ys=^"
# SECURITY WARNING: don't run with debug turned on in production!
# Quick-start development settings - unsuitable for production
SECRET_KEY = 'django-insecure-key-for-development'
DEBUG = True
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"
ALLOWED_HOSTS = []
# Application definition
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"django.contrib.sites",
"django.contrib.gis", # Add GeoDjango
"pghistory", # Add django-pghistory
"pgtrigger", # Required by django-pghistory
"history.apps.HistoryConfig", # History timeline app
"allauth",
"allauth.account",
"allauth.socialaccount",
"allauth.socialaccount.providers.google",
"allauth.socialaccount.providers.discord",
"django_cleanup",
"django_filters",
"django_htmx",
"whitenoise",
"django_tailwind_cli",
"core",
"accounts",
"companies",
"parks",
"rides",
"reviews",
"email_service",
"media.apps.MediaConfig",
"moderation",
"history_tracking",
"designers",
"analytics",
"location",
"search.apps.SearchConfig", # Add search app
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'django.contrib.sites.apps.SitesConfig',
'django.contrib.humanize.apps.HumanizeConfig',
'django_nyt.apps.DjangoNytConfig',
'mptt',
'sorl.thumbnail',
'wiki.apps.WikiConfig', # Main wiki app
'wiki.plugins.parks.apps.ParksPluginConfig', # Parks plugin
]
MIDDLEWARE = [
"django.middleware.cache.UpdateCacheMiddleware",
"django.middleware.security.SecurityMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"core.middleware.PgHistoryContextMiddleware", # Add history context tracking
"allauth.account.middleware.AccountMiddleware",
"django.middleware.cache.FetchFromCacheMiddleware",
"django_htmx.middleware.HtmxMiddleware",
"analytics.middleware.PageViewMiddleware", # Add our page view tracking
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
ROOT_URLCONF = "thrillwiki.urls"
ROOT_URLCONF = 'thrillwiki.urls'
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [os.path.join(BASE_DIR, "templates")],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
"moderation.context_processors.moderation_access",
]
}
}
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [BASE_DIR / 'templates'],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
'django.template.context_processors.media',
'django.template.context_processors.static',
'django.template.context_processors.tz',
],
},
},
]
WSGI_APPLICATION = "thrillwiki.wsgi.application"
WSGI_APPLICATION = 'thrillwiki.wsgi.application'
# Database
DATABASES = {
"default": {
"ENGINE": "django.contrib.gis.db.backends.postgis", # Update to use PostGIS
"NAME": "thrillwiki",
"USER": "wiki",
"PASSWORD": "thrillwiki",
"HOST": "192.168.86.3",
"PORT": "5432",
'default': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': 'thrillwiki',
'USER': 'postgres',
'PASSWORD': 'postgres',
'HOST': 'localhost',
'PORT': '5432',
}
}
# Cache settings
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
"LOCATION": "unique-snowflake",
"TIMEOUT": 300, # 5 minutes
"OPTIONS": {"MAX_ENTRIES": 1000},
}
}
CACHE_MIDDLEWARE_SECONDS = 1 # 5 minutes
CACHE_MIDDLEWARE_KEY_PREFIX = "thrillwiki"
# Password validation
AUTH_PASSWORD_VALIDATORS = [
{
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
},
{
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
},
{
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
},
{
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
},
]
# Wiki settings
WIKI_ACCOUNT_HANDLING = True
WIKI_ACCOUNT_SIGNUP_ALLOWED = True
WIKI_ANONYMOUS = True
WIKI_ANONYMOUS_WRITE = False
WIKI_MARKDOWN_HTML_ATTRIBUTES = True
WIKI_MARKDOWN_HTML_STYLES = True
SITE_ID = 1
# Internationalization
LANGUAGE_CODE = "en-us"
TIME_ZONE = "America/New_York"
LANGUAGE_CODE = 'en-us'
TIME_ZONE = 'UTC'
USE_I18N = True
USE_TZ = True
# Static files (CSS JavaScript Images)
STATIC_URL = "static/"
STATICFILES_DIRS = [BASE_DIR / "static"]
STATIC_ROOT = os.path.join(BASE_DIR, "staticfiles")
# Media files
MEDIA_URL = "/media/"
MEDIA_ROOT = os.path.join(BASE_DIR, "media")
# Default primary key field type
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
# Authentication settings
AUTHENTICATION_BACKENDS = [
"django.contrib.auth.backends.ModelBackend",
"allauth.account.auth_backends.AuthenticationBackend",
# Static files (CSS, JavaScript, Images)
STATIC_URL = 'static/'
STATIC_ROOT = BASE_DIR / 'staticfiles'
STATICFILES_DIRS = [
BASE_DIR / 'static',
]
# 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 = "/"
# Media files
MEDIA_URL = '/media/'
MEDIA_ROOT = BASE_DIR / 'uploads'
# 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
# Default primary key field type
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
# Email settings
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"
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'

View File

@@ -1,82 +1,6 @@
from django.contrib import admin
from django.urls import path, include
from django.conf import settings
from django.conf.urls.static import static
from django.views.static import serve
from accounts import views as accounts_views
from django.views.generic import TemplateView
from .views import HomeView, SearchView
from . import views
import os
urlpatterns = [
path("admin/", admin.site.urls),
# 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",
),
path('admin/', admin.site.urls),
]
# 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
View File

@@ -1,4 +1,5 @@
version = 1
revision = 1
requires-python = ">=3.13"
[[package]]
@@ -63,6 +64,23 @@ wheels = [
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]black-24.10.0-py3-none-any.whl", hash = "sha256:[AWS-SECRET-REMOVED]9853e47a294a3dd963c1dd7d", size = 206898 },
]
[[package]]
name = "bleach"
version = "6.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "webencodings" },
]
sdist = { url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]bleach-6.2.0.tar.gz", hash = "sha256:[AWS-SECRET-REMOVED]b1317a7e1ba69b56e95f991f", size = 203083 }
wheels = [
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]bleach-6.2.0-py3-none-any.whl", hash = "sha256:[AWS-SECRET-REMOVED]36f554da4e432fdd63f31e5e", size = 163406 },
]
[package.optional-dependencies]
css = [
{ name = "tinycss2" },
]
[[package]]
name = "certifi"
version = "2024.12.14"
@@ -266,6 +284,18 @@ dependencies = [
]
sdist = { url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]django_allauth-65.3.0.tar.gz", hash = "sha256:[AWS-SECRET-REMOVED]a86d873a8a9fd8f0ec57bbbf", size = 1546784 }
[[package]]
name = "django-classy-tags"
version = "4.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
]
sdist = { url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]django-classy-tags-4.1.0.tar.gz", hash = "sha256:[AWS-SECRET-REMOVED]bb750b2490a17b161774ee59", size = 24692 }
wheels = [
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]django_classy_tags-4.1.0-py3-none-any.whl", hash = "sha256:[AWS-SECRET-REMOVED]a160a847ff449588d4e01e55", size = 14095 },
]
[[package]]
name = "django-cleanup"
version = "9.0.0"
@@ -313,6 +343,54 @@ wheels = [
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]django_htmx-1.21.0-py3-none-any.whl", hash = "sha256:[AWS-SECRET-REMOVED]e7ccd2963495e69afbdb6abe", size = 6901 },
]
[[package]]
name = "django-htmx-autocomplete"
version = "1.0.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
]
sdist = { url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]django_htmx_autocomplete-1.0.5.tar.gz", hash = "sha256:[AWS-SECRET-REMOVED]17bcac3ff0b70766e354ad80", size = 41127 }
wheels = [
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]django_htmx_autocomplete-1.0.5-py3-none-any.whl", hash = "sha256:[AWS-SECRET-REMOVED]3572e8742fe5dfa848298735", size = 52127 },
]
[[package]]
name = "django-js-asset"
version = "3.0.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
]
sdist = { url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]django_js_asset-3.0.1.tar.gz", hash = "sha256:[AWS-SECRET-REMOVED]2f6a6bfe93577dee793dc378", size = 7701 }
wheels = [
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]django_js_asset-3.0.1-py3-none-any.whl", hash = "sha256:[AWS-SECRET-REMOVED]43c282cb64fe6c13d7ca4c10", size = 4283 },
]
[[package]]
name = "django-mptt"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django-js-asset" },
]
sdist = { url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]django_mptt-0.16.0.tar.gz", hash = "sha256:[AWS-SECRET-REMOVED]999b10903b09de62bee84c8e", size = 69886 }
wheels = [
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]django_mptt-0.16.0-py3-none-any.whl", hash = "sha256:[AWS-SECRET-REMOVED]5f8472b690dbaf737d2af3b5", size = 115934 },
]
[[package]]
name = "django-nyt"
version = "1.4.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
]
sdist = { url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]django_nyt-1.4.1.tar.gz", hash = "sha256:[AWS-SECRET-REMOVED]1d987ee81bf6a0ac3352b4a1", size = 28960 }
wheels = [
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]django_nyt-1.4.1-py3-none-any.whl", hash = "sha256:[AWS-SECRET-REMOVED]47cdb2cc10e7c4f2fecd6aff", size = 41084 },
]
[[package]]
name = "django-oauth-toolkit"
version = "3.0.1"
@@ -353,6 +431,19 @@ wheels = [
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]django_pgtrigger-4.13.3-py3-none-any.whl", hash = "sha256:[AWS-SECRET-REMOVED]275d86ad756b90c307df3ca4", size = 34059 },
]
[[package]]
name = "django-sekizai"
version = "4.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
{ name = "django-classy-tags" },
]
sdist = { url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]django-sekizai-4.1.0.tar.gz", hash = "sha256:[AWS-SECRET-REMOVED]3145bff11e58622fc653cdad", size = 14591 }
wheels = [
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]django_sekizai-4.1.0-py3-none-any.whl", hash = "sha256:[AWS-SECRET-REMOVED]9a1304a9b9e8b191229e2e4a", size = 8597 },
]
[[package]]
name = "django-simple-history"
version = "3.7.0"
@@ -508,6 +599,15 @@ wheels = [
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]jwcrypto-1.5.6-py3-none-any.whl", hash = "sha256:[AWS-SECRET-REMOVED]8be71f67f03566692fd55789", size = 92520 },
]
[[package]]
name = "markdown"
version = "3.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]Markdown-3.6.tar.gz", hash = "sha256:[AWS-SECRET-REMOVED]a16cb35fa8ed8c2ddfad0224", size = 354715 }
wheels = [
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]Markdown-3.6-py3-none-any.whl", hash = "sha256:[AWS-SECRET-REMOVED]d40aa410fbc3b4ee832c850f", size = 105381 },
]
[[package]]
name = "mccabe"
version = "0.7.0"
@@ -732,6 +832,19 @@ wheels = [
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:[AWS-SECRET-REMOVED]44f25406ffaebd50bd98dacb", size = 22997 },
]
[[package]]
name = "pymdown-extensions"
version = "10.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown" },
{ name = "pyyaml" },
]
sdist = { url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]pymdown_extensions-10.5.tar.gz", hash = "sha256:[AWS-SECRET-REMOVED]a294de1989f29d20096cfd0b", size = 788318 }
wheels = [
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]pymdown_extensions-10.5-py3-none-any.whl", hash = "sha256:[AWS-SECRET-REMOVED]6ac4c5eb01e27464b80fe879", size = 241130 },
]
[[package]]
name = "pyopenssl"
version = "24.3.0"
@@ -820,6 +933,23 @@ wheels = [
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]python_slugify-8.0.4-py2.py3-none-any.whl", hash = "sha256:[AWS-SECRET-REMOVED]5fd9e3a70164fc8c50faa6b8", size = 10051 },
]
[[package]]
name = "pyyaml"
version = "6.0.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]pyyaml-6.0.2.tar.gz", hash = "sha256:[AWS-SECRET-REMOVED]e591037abe114850ff7bbc3e", size = 130631 }
wheels = [
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:[AWS-SECRET-REMOVED]c3170801852d752aa7a783ba", size = 181309 },
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:[AWS-SECRET-REMOVED]997de9efef88badc3bb9e2d1", size = 171679 },
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:[AWS-SECRET-REMOVED]0eef8c8f44e0254ab3b07133", size = 733428 },
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:[AWS-SECRET-REMOVED]73d41e99c4fff6b6c3276484", size = 763361 },
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:[AWS-SECRET-REMOVED]f1e08d9b561cb41b845f69d5", size = 759523 },
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:[AWS-SECRET-REMOVED]34e29c2a514c2c0c5fe971cc", size = 726660 },
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:[AWS-SECRET-REMOVED]899c72eacb5a668902e4d652", size = 751597 },
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:[AWS-SECRET-REMOVED]36abab80d4681424b84c1183", size = 140527 },
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:[AWS-SECRET-REMOVED]dd57cdeb95f3f2e085687563", size = 156446 },
]
[[package]]
name = "redis"
version = "5.2.1"
@@ -877,6 +1007,15 @@ wheels = [
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:[AWS-SECRET-REMOVED]56c89140852d1120324e8686", size = 9755 },
]
[[package]]
name = "sorl-thumbnail"
version = "12.11.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]sorl_thumbnail-12.11.0.tar.gz", hash = "sha256:[AWS-SECRET-REMOVED]f439b2e17b938b91eea463b3", size = 667102 }
wheels = [
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]sorl_thumbnail-12.11.0-py3-none-any.whl", hash = "sha256:[AWS-SECRET-REMOVED]4af5e9dc3f31cb605df765b5", size = 42789 },
]
[[package]]
name = "sqlparse"
version = "0.5.3"
@@ -912,6 +1051,9 @@ dependencies = [
{ name = "django-cors-headers" },
{ name = "django-filter" },
{ name = "django-htmx" },
{ name = "django-htmx-autocomplete" },
{ name = "django-mptt" },
{ name = "django-nyt" },
{ name = "django-oauth-toolkit" },
{ name = "django-pghistory" },
{ name = "django-simple-history" },
@@ -929,7 +1071,9 @@ dependencies = [
{ name = "pytest-playwright" },
{ name = "python-dotenv" },
{ name = "requests" },
{ name = "sorl-thumbnail" },
{ name = "whitenoise" },
{ name = "wiki" },
]
[package.metadata]
@@ -946,6 +1090,9 @@ requires-dist = [
{ name = "django-cors-headers", specifier = ">=4.3.1" },
{ name = "django-filter", specifier = ">=23.5" },
{ name = "django-htmx", specifier = ">=1.17.2" },
{ name = "django-htmx-autocomplete", specifier = ">=1.0.5" },
{ name = "django-mptt", specifier = ">=0.16.0" },
{ name = "django-nyt", specifier = ">=1.4.1" },
{ name = "django-oauth-toolkit", specifier = ">=3.0.1" },
{ name = "django-pghistory", specifier = ">=3.5.2" },
{ name = "django-simple-history", specifier = ">=3.5.0" },
@@ -963,7 +1110,21 @@ requires-dist = [
{ name = "pytest-playwright", specifier = ">=0.4.3" },
{ name = "python-dotenv", specifier = ">=1.0.1" },
{ name = "requests", specifier = ">=2.32.3" },
{ name = "sorl-thumbnail", specifier = ">=12.11.0" },
{ name = "whitenoise", specifier = ">=6.6.0" },
{ name = "wiki", specifier = ">=0.11.2" },
]
[[package]]
name = "tinycss2"
version = "1.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "webencodings" },
]
sdist = { url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]tinycss2-1.4.0.tar.gz", hash = "sha256:[AWS-SECRET-REMOVED]c1ae10ebccdea16fb404a9b7", size = 87085 }
wheels = [
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]tinycss2-1.4.0-py3-none-any.whl", hash = "sha256:[AWS-SECRET-REMOVED]4bb905a5775adb0d884c5289", size = 26610 },
]
[[package]]
@@ -1040,6 +1201,15 @@ wheels = [
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]urllib3-2.3.0-py3-none-any.whl", hash = "sha256:[AWS-SECRET-REMOVED]710050facf0dd6911440e3df", size = 128369 },
]
[[package]]
name = "webencodings"
version = "0.5.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]webencodings-0.5.1.tar.gz", hash = "sha256:[AWS-SECRET-REMOVED]865afcc4aab16748587e1923", size = 9721 }
wheels = [
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:[AWS-SECRET-REMOVED]3f95be16fc9acd2947514a78", size = 11774 },
]
[[package]]
name = "whitenoise"
version = "6.8.2"
@@ -1049,6 +1219,26 @@ wheels = [
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]whitenoise-6.8.2-py3-none-any.whl", hash = "sha256:[AWS-SECRET-REMOVED]ab9726e5772ac50fb45d2280", size = 20158 },
]
[[package]]
name = "wiki"
version = "0.11.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "bleach", extra = ["css"] },
{ name = "django" },
{ name = "django-mptt" },
{ name = "django-nyt" },
{ name = "django-sekizai" },
{ name = "markdown" },
{ name = "pillow" },
{ name = "pymdown-extensions" },
{ name = "sorl-thumbnail" },
]
sdist = { url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]wiki-0.11.2.tar.gz", hash = "sha256:[AWS-SECRET-REMOVED]4140b4c9c64497736c1594d7", size = 2274191 }
wheels = [
{ url = "https://files.pythonhosted.[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]wiki-0.11.2-py3-none-any.whl", hash = "sha256:[AWS-SECRET-REMOVED]a1182bd105f2cbfebbeb20aa", size = 2436316 },
]
[[package]]
name = "zope-interface"
version = "7.2"

1
wiki/__init__.py Normal file
View File

@@ -0,0 +1 @@
default_app_config = "wiki.apps.WikiConfig"

11
wiki/apps.py Normal file
View File

@@ -0,0 +1,11 @@
from django.apps import AppConfig
class WikiConfig(AppConfig):
name = 'wiki'
verbose_name = 'Wiki'
def ready(self):
"""
Register signals and perform other initialization
"""
pass

View File

@@ -0,0 +1 @@
default_app_config = "wiki.plugins.parks.apps.ParksPluginConfig"

View File

@@ -0,0 +1,13 @@
from django.apps import AppConfig
class ParksPluginConfig(AppConfig):
name = "wiki.plugins.parks"
label = "wiki_parks"
verbose_name = "Wiki Parks Plugin"
def ready(self):
"""
Register plugin with wiki system when the app is ready.
Plugin registration is deferred until wiki core is available.
"""
pass

View File

@@ -0,0 +1,34 @@
from django.db import models
from django.utils.translation import gettext_lazy as _
class ParkMetadata(models.Model):
article = models.OneToOneField(
'wiki.Article', # Using string reference to avoid import issues
on_delete=models.CASCADE,
related_name='park_metadata'
)
operator = models.CharField(
max_length=255,
verbose_name=_('Operator'),
blank=True
)
opened_date = models.DateField(
verbose_name=_('Opening Date'),
null=True,
blank=True
)
location = models.CharField(
max_length=255,
verbose_name=_('Location'),
blank=True
)
class Meta:
verbose_name = _('Park Metadata')
verbose_name_plural = _('Park Metadata')
def __str__(self):
return f"Park info for {self.article.current_revision.title}"

View File

@@ -0,0 +1,14 @@
from django.utils.translation import gettext as _
class ParksPlugin:
"""
Plugin for handling parks in the wiki system.
Core registration will be added later.
"""
slug = 'parks'
sidebar = {
'headline': _('Park Information'),
'icon_class': 'fa-info-circle',
'template': 'wiki/plugins/parks/sidebar.html',
}