Compare commits

...

44 Commits

Author SHA1 Message Date
pacnpal
dfe6194039 ok 2024-11-14 04:15:24 +00:00
pacnpal
e63677e8c0 the ghost of the sea 2024-11-14 02:48:41 +00:00
pacnpal
af3e255a2e Fix moderation submission template:
- Fix unclosed if tags and template structure
- Fix ride category selection and stats display
- Fix location map display and remove duplicates
- Fix grid layout and closing tags
2024-11-14 02:31:28 +00:00
pacnpal
ebbf772669 Fix moderation submission display:
- Fix location map display in view mode
- Remove duplicate location map
- Fix grid layout and closing tags
- Show location map before other fields
2024-11-14 02:24:01 +00:00
pacnpal
891c29beff Fix moderation submission display:
- Add location map template for view mode
- Add coaster fields template for stats
- Fix location and stats display in both view and edit modes
2024-11-14 02:16:04 +00:00
pacnpal
e86cb95f14 Fix moderation submission edit view:
- Add coaster stats fields template
- Fix ride category selection and stats display
- Fix location widget and map initialization
2024-11-14 02:10:14 +00:00
pacnpal
fd97ed31cb Fix map initialization and park area selection in moderation submission edit view 2024-11-14 01:56:56 +00:00
pacnpal
d9990ae241 Fix ride category, status, and park area selection in moderation submission edit view 2024-11-14 01:32:37 +00:00
pacnpal
da0ee1acfa Fix ride category selection and stats field display in moderation submission edit view 2024-11-14 01:24:27 +00:00
pacnpal
30b786d51e Make sign in and registration screens scrollable
- Remove fixed height constraints from main pages
- Add responsive padding
- Add max-height and overflow handling to modals
- Make modal headers sticky
- Improve overall scrolling behavior
2024-11-14 01:02:54 +00:00
pacnpal
8d70bf8994 [Documentation] Create context documentation system
- Created comprehensive documentation structure
- Added productContext.md for product understanding
- Added activeContext.md for current development status
- Added systemPatterns.md for architecture patterns
- Added developmentWorkflow.md for process standards
- Added operationalContext.md for runtime details
- Added projectBoundaries.md for technical constraints
- Added techContext.md for technology stack
- Documented all major aspects of the system
- Established foundation for maintaining context
2024-11-13 23:40:36 +00:00
pacnpal
edc9d66849 Add enhanced toast notifications for moderation actions
- Add color-coded toast notifications for different actions
- Use appropriate icons for each action type
- Improve toast positioning and z-index
- Add smooth transitions for better UX
- Remove duplicate toast implementation
2024-11-13 22:02:59 +00:00
pacnpal
8265348a83 code commit 2024-11-13 21:59:49 +00:00
pacnpal
8f7f7add2d Fix moderation dashboard CSS formatting
- Update category and status select fields with consistent styling
- Add proper dark mode support for form elements
- Improve form field spacing and alignment
- Standardize input field styling across the dashboard
2024-11-13 21:31:02 +00:00
pacnpal
7ec4d964dc Update loading indicator styling for dark mode compatibility 2024-11-13 18:59:04 +00:00
pacnpal
d68c927a00 Move loading indicator to center of empty state div in submission list 2024-11-13 18:57:48 +00:00
pacnpal
caba5c6158 Update active tab indicator colors in light mode to use blue instead of grey 2024-11-13 18:55:14 +00:00
pacnpal
131ef7ceb0 Update dark mode styling for submission list to match site theme 2024-11-13 18:53:03 +00:00
pacnpal
a30f3ef644 Update dark mode styling for moderation dashboard to match site theme 2024-11-13 18:51:15 +00:00
pacnpal
2e1040e3a6 Add Moderation Dashboard title to the top of the dashboard view 2024-11-13 18:48:18 +00:00
pacnpal
751d21098d Add refresh button to moderation dashboard that maintains current filters when refreshing content 2024-11-13 18:46:32 +00:00
pacnpal
1acfe9d29e Improve moderation dashboard UI and functionality
- Add status tabs (Pending, Approved, Rejected, Escalated)
- Implement HTMX for smooth tab switching and status changes
- Add proper permissions for escalated submissions
- Change Status filter to Submission Type (Text/Photo)
- Move navigation into dashboard content
- Fix tab menu visibility and transitions
- Add contextual loading indicator
- Update styling to match dark theme
- Ensure consistent styling across components
2024-11-13 18:38:03 +00:00
pacnpal
6a9154ce69 Improve moderation dashboard UI and functionality
- Add status tabs (Pending, Approved, Rejected, Escalated)
- Implement HTMX for smooth tab switching and status changes
- Add proper permissions for escalated submissions
- Change Status filter to Submission Type (Text/Photo)
- Move navigation into dashboard content
- Fix tab menu visibility and transitions
- Add contextual loading indicator
- Update styling to match dark theme
- Ensure consistent styling across components
2024-11-13 18:37:36 +00:00
pacnpal
15e56c9770 Add CSRF token handling to HTMX requests:
- Configure HTMX to include CSRF token in request headers
- Add global HTMX configuration in dashboard.html
- Fix CSRF token missing error in moderation actions
2024-11-13 17:16:43 +00:00
pacnpal
09ee45f6c7 Fix duplicate menus in moderation dashboard:
- Move navigation to separate moderation_nav.html partial
- Include navigation only once in dashboard.html base template
- Remove duplicate navigation from child templates
2024-11-13 17:09:22 +00:00
pacnpal
177117f4d6 Improve moderation dashboard UI and HTMX integration:
- Create partial templates for dashboard, edit submissions, and photo submissions
- Update views to properly handle HTMX requests
- Fix duplicate UI issues when navigating
- Improve overall UI design and transitions
2024-11-13 17:04:42 +00:00
pacnpal
96341bfd82 ignore tailwind 2024-11-13 16:45:09 +00:00
pacnpal
f011d58c6d Fix duplicate UI in moderation dashboard by separating HTMX partial templates and full page templates 2024-11-13 16:34:31 +00:00
pacnpal
983c101ed1 Added support for Django's built-in superuser to access moderation features. Modified context processor and views to check both role-based and is_superuser permissions. 2024-11-13 16:13:54 +00:00
pacnpal
97a3555e81 Bypass Turnstile validation when DEBUG is True 2024-11-13 16:10:25 +00:00
pacnpal
ec626b4124 Disable Cloudflare Turnstile on login/signup pages when DEBUG is True 2024-11-13 15:56:42 +00:00
pacnpal
537ea0fc07 Improve code readability in moderation models using walrus operator 2024-11-13 15:51:54 +00:00
pacnpal
be07a17460 Improve code readability with boolean expressions and better control flow 2024-11-13 15:50:38 +00:00
pacnpal
1c03e4acb8 Improve type hints and code organization in moderation views 2024-11-13 15:47:19 +00:00
pacnpal
a5ebeb51dc add tailwind to gitignore as generated on the fly 2024-11-13 15:42:57 +00:00
pacnpal
1ee4b00961 Add password confirmation, validation, and email notification for password changes 2024-11-13 15:17:07 +00:00
pacnpal
5a1fdb6d16 Set max width of settings container to 800px for better readability and focus 2024-11-13 15:05:56 +00:00
pacnpal
78355c60f9 Updated moderation link in navbar to point to dashboard instead of edit submissions - Improves navigation flow for moderators, admins, and superusers 2024-11-13 15:03:04 +00:00
pacnpal
d8a65f4e81 Verified admin and superuser access to moderation dashboard - Access control already properly implemented through ModeratorRequiredMixin with ADMIN and SUPERUSER roles included in permission checks 2024-11-13 14:57:49 +00:00
pacnpal
cac6335bb7 Fix stats cards styling on home page - Add consistent flex layout for perfect centering - Fix HTML structure in parks card - Standardize hover effects and shadows across all cards 2024-11-13 14:55:11 +00:00
pacnpal
7f4de7c2ec Fixed mod models 2024-11-13 14:53:31 +00:00
pacnpal
08e97f21b7 Reorganize moderation dashboard structure:
- Move from /moderation/admin to /moderation to avoid conflicts
- Improve template organization with partials
- Add HTMX-powered navigation and filtering
- Add smooth transitions and loading states
- Improve photo submission handling
- Add review notes functionality
- Add confirmation dialogs
2024-11-13 14:50:34 +00:00
pacnpal
9ee380c3ea Enhance moderation dashboard UI and UX:
- Add HTMX-powered filtering with instant updates
- Add smooth transitions and loading states
- Improve visual hierarchy and styling
- Add review notes functionality
- Add confirmation dialogs for actions
- Make navigation sticky
- Add hover effects and visual feedback
- Improve dark mode support
2024-11-13 14:38:38 +00:00
pacnpal
d2c9d02523 Enhance moderation dashboard UI and UX:
- Add HTMX-powered filtering with instant updates
- Add smooth transitions and loading states
- Improve visual hierarchy and styling
- Add review notes functionality
- Add confirmation dialogs for actions
- Make navigation sticky
- Add hover effects and visual feedback
- Improve dark mode support
2024-11-13 14:38:17 +00:00
151 changed files with 9532 additions and 3771 deletions

2
.gitignore vendored
View File

@@ -33,3 +33,5 @@ parks/__pycache__/views.cpython-312.pyc
thrillwiki/__pycache__/urls.cpython-312.pyc
thrillwiki/__pycache__/views.cpython-312.pyc
.pytest_cache.github
static/css/tailwind.css
static/css/tailwind.css

View File

@@ -1,4 +1,4 @@
# Generated by Django 5.1.2 on 2024-10-28 21:50
# Generated by Django 5.1.3 on 2024-11-12 18:07
import django.contrib.auth.models
import django.contrib.auth.validators
@@ -60,6 +60,18 @@ class Migration(migrations.Migration):
verbose_name="username",
),
),
(
"first_name",
models.CharField(
blank=True, max_length=150, verbose_name="first name"
),
),
(
"last_name",
models.CharField(
blank=True, max_length=150, verbose_name="last name"
),
),
(
"email",
models.EmailField(
@@ -97,18 +109,6 @@ class Migration(migrations.Migration):
unique=True,
),
),
(
"first_name",
models.CharField(
default="", max_length=150, verbose_name="first name"
),
),
(
"last_name",
models.CharField(
default="", max_length=150, verbose_name="last name"
),
),
(
"role",
models.CharField(

View File

@@ -5,11 +5,16 @@ from django.core.exceptions import ValidationError
class TurnstileMixin:
"""
Mixin to handle Cloudflare Turnstile validation.
Bypasses validation when DEBUG is True.
"""
def validate_turnstile(self, request):
"""
Validate the Turnstile response token.
Skips validation when DEBUG is True.
"""
if settings.DEBUG:
return
token = request.POST.get('cf-turnstile-response')
if not token:
raise ValidationError('Please complete the Turnstile challenge.')

View File

@@ -1,14 +1,24 @@
from django import template
from django.conf import settings
from django.template.loader import render_to_string
register = template.Library()
@register.inclusion_tag('accounts/turnstile_widget.html')
@register.simple_tag
def turnstile_widget():
"""
Template tag to render the Cloudflare Turnstile widget.
When DEBUG is True, renders an empty template.
When DEBUG is False, renders the normal widget.
Usage: {% load turnstile_tags %}{% turnstile_widget %}
"""
return {
if settings.DEBUG:
template_name = 'accounts/turnstile_widget_empty.html'
context = {}
else:
template_name = 'accounts/turnstile_widget.html'
context = {
'site_key': settings.TURNSTILE_SITE_KEY
}
return render_to_string(template_name, context)

View File

@@ -30,6 +30,7 @@ from django_htmx.http import HttpResponseClientRefresh
from django.contrib.sites.models import Site
from django.contrib.sites.requests import RequestSite
from contextlib import suppress
import re
if TYPE_CHECKING:
from django.contrib.sites.models import Site
@@ -46,10 +47,7 @@ class CustomLoginView(TurnstileMixin, LoginView):
return self.form_invalid(form)
response = super().form_valid(form)
if getattr(self.request, 'htmx', False):
return HttpResponseClientRefresh()
return response
return HttpResponseClientRefresh() if getattr(self.request, 'htmx', False) else response
def form_invalid(self, form):
if getattr(self.request, 'htmx', False):
@@ -64,7 +62,7 @@ class CustomLoginView(TurnstileMixin, LoginView):
if getattr(request, 'htmx', False):
return render(
request,
'account/partials/login_form.html',
'account/partials/login_modal.html',
self.get_context_data()
)
return super().get(request, *args, **kwargs)
@@ -76,7 +74,27 @@ class CustomSignupView(TurnstileMixin, SignupView):
except ValidationError as e:
form.add_error(None, str(e))
return self.form_invalid(form)
return super().form_valid(form)
response = super().form_valid(form)
return HttpResponseClientRefresh() if getattr(self.request, 'htmx', False) else response
def form_invalid(self, form):
if getattr(self.request, 'htmx', False):
return render(
self.request,
'account/partials/signup_modal.html',
self.get_context_data(form=form)
)
return super().form_invalid(form)
def get(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
if getattr(request, 'htmx', False):
return render(
request,
'account/partials/signup_modal.html',
self.get_context_data()
)
return super().get(request, *args, **kwargs)
@login_required
def user_redirect_view(request: HttpRequest) -> HttpResponse:
@@ -91,7 +109,6 @@ def handle_social_login(request: HttpRequest, email: str) -> HttpResponse:
del request.session['socialaccount_sociallogin']
messages.success(request, 'Successfully logged in')
return redirect('/')
return redirect('/')
def email_required(request: HttpRequest) -> HttpResponse:
if not request.session.get('socialaccount_sociallogin'):
@@ -170,27 +187,64 @@ class SettingsView(LoginRequiredMixin, TemplateView):
user.save()
messages.success(request, 'Profile updated successfully')
def _validate_password(self, password: str) -> bool:
"""Validate password meets requirements."""
return (
len(password) >= 8 and
bool(re.search(r'[A-Z]', password)) and
bool(re.search(r'[a-z]', password)) and
bool(re.search(r'[0-9]', password))
)
def _send_password_change_confirmation(self, request: HttpRequest, user: User) -> None:
"""Send password change confirmation email."""
site = get_current_site(request)
context = {
'user': user,
'site_name': site.name,
}
email_html = render_to_string('accounts/email/password_change_confirmation.html', context)
EmailService.send_email(
to=user.email,
subject='Password Changed Successfully',
text='Your password has been changed successfully.',
site=site,
html=email_html
)
def _handle_password_change(self, request: HttpRequest) -> Optional[HttpResponseRedirect]:
user = cast(User, request.user)
old_password = request.POST.get('old_password', '')
new_password = request.POST.get('new_password', '')
confirm_password = request.POST.get('confirm_password', '')
if not user.check_password(old_password):
messages.error(request, 'Current password is incorrect')
return None
if new_password != confirm_password:
messages.error(request, 'New passwords do not match')
return None
if not self._validate_password(new_password):
messages.error(request, 'Password must be at least 8 characters and contain uppercase, lowercase, and numbers')
return None
user.set_password(new_password)
user.save()
messages.success(request, 'Password changed successfully')
self._send_password_change_confirmation(request, user)
messages.success(request, 'Password changed successfully. Please check your email for confirmation.')
return HttpResponseRedirect(reverse('account_login'))
def _handle_email_change(self, request: HttpRequest) -> None:
if not (new_email := request.POST.get('new_email')):
messages.error(request, 'New email is required')
return
if new_email := request.POST.get('new_email'):
self._send_email_verification(request, new_email)
messages.success(request, 'Verification email sent to your new email address')
else:
messages.error(request, 'New email is required')
def _send_email_verification(self, request: HttpRequest, new_email: str) -> None:
user = cast(User, request.user)

View File

@@ -1,4 +1,4 @@
# Generated by Django 5.1.2 on 2024-11-04 00:46
# Generated by Django 5.1.3 on 2024-11-12 18:07
import django.db.models.deletion
from django.db import migrations, models

View File

@@ -0,0 +1,60 @@
# Active Context
## Current Focus
- Moderation system development and enhancement
- Dashboard interface improvements
- Submission review workflow
## Recent Changes
Working on moderation system components:
- Dashboard interface
- Submission list views
- Moderation navigation
- Content review workflow
## Active Files
### Moderation System
- moderation/models.py
- moderation/urls.py
- moderation/views.py
- templates/moderation/dashboard.html
- templates/moderation/partials/
- submission_list.html
- moderation_nav.html
- dashboard_content.html
## Next Steps
1. Review and enhance moderation dashboard functionality
2. Implement remaining submission review workflows
3. Test moderation system end-to-end
4. Document moderation patterns and guidelines
## Current Development State
- Using Django for backend framework
- HTMX for dynamic interactions
- AlpineJS for client-side functionality
- Tailwind CSS for styling
- Python manage.py tailwind runserver for development
## Testing Requirements
- Verify all moderation workflows
- Test submission review process
- Validate user role permissions
- Check notification systems
## Deployment Notes
- Site runs at http://thrillwiki.com
- Changes must be committed to git and pushed to main
- HTMX templates located in partials folders by model
## Active Issues/Considerations
- Ensure proper separation of moderation partials
- Maintain consistent HTMX patterns
- Follow established Git workflow
- Keep documentation updated
## Recent Decisions
- Using partial templates for modular HTMX components
- Implementing dedicated moderation dashboard
- Structured submission review process

View File

@@ -0,0 +1,199 @@
# Development Workflow
## Development Process
### Local Development
1. Server Management
```bash
python manage.py tailwind runserver # Required command for local development
```
2. URL Access
- Production: http://thrillwiki.com
- Avoid using localhost
### Code Organization
1. Template Structure
- Base templates in templates/
- HTMX partials in model-specific partials/ folders
- Consistent naming conventions
- Reusable components
2. Feature Development
- Model changes
- URL configuration
- View implementation
- Template creation
- HTMX/AlpineJS integration
### Git Workflow
1. Version Control
- All changes must be committed
- Detailed commit messages required
- Push directly to main branch
- Regular commits for trackability
2. Commit Message Format
```
[Component] Brief description of change
- Detailed bullet points of changes
- Impact on other components
- Testing performed
```
## Testing Patterns
### Test Structure
- tests/ directory for test files
- Coverage tracking (.coverage)
- README.md in tests/ for documentation
- test_runner.py for custom configurations
### Testing Requirements
1. Functionality Testing
- Core features
- Edge cases
- Error conditions
- User workflows
2. Integration Testing
- Cross-component functionality
- External service integration
- Database operations
- Cache behavior
3. UI Testing
- HTMX interactions
- AlpineJS functionality
- Responsive design
- Browser compatibility
### Testing Guidelines
- Write tests for new features
- Update tests for modifications
- Maintain test coverage
- Document test scenarios
## Release Process
### Pre-Release Checklist
1. Code Quality
- All tests passing
- Coverage maintained
- Linting clean
- Documentation updated
2. Feature Verification
- Core functionality tested
- HTMX interactions verified
- AlpineJS behavior confirmed
- Cross-browser testing
### Deployment Steps
1. Code Preparation
- Commit all changes
- Push to main branch
- Verify build success
2. Post-Deployment
- Verify site functionality
- Check error logs
- Monitor performance
- Validate new features
## Project Standards
### Code Style
1. Python
- Follow PEP 8
- Use type hints
- Document functions
- Clear variable names
2. Templates
- Consistent indentation
- Organized partial templates
- Clear component structure
- Documented HTMX attributes
3. JavaScript
- AlpineJS best practices
- Clean function names
- Documented interactions
- Minimal complexity
### Documentation Requirements
1. Code Documentation
- Docstrings for Python code
- Comment complex logic
- Update README files
- Maintain context files
2. Template Documentation
- Document HTMX patterns
- Explain AlpineJS usage
- Note partial dependencies
- Document data requirements
### Best Practices
1. HTMX Usage
- Partial templates in dedicated folders
- Clear target attributes
- Documented triggers
- Error handling
2. AlpineJS Integration
- Minimal state management
- Clear x-data structures
- Documented behaviors
- Progressive enhancement
3. Django Patterns
- Clear view logic
- Optimized queries
- Proper model relationships
- Efficient template inheritance
## Quality Assurance
### Code Review Process
1. Self-Review
- Test coverage
- Documentation
- Code standards
- Performance impact
2. Testing Requirements
- Local verification
- Production testing
- Error handling
- Edge cases
### Performance Standards
1. Page Load
- Optimize queries
- Minimize requests
- Efficient templates
- Proper caching
2. Interaction Speed
- Quick HTMX responses
- Smooth transitions
- Responsive UI
- Error feedback
### Maintenance
1. Regular Tasks
- Update dependencies
- Review error logs
- Monitor performance
- Update documentation
2. Code Health
- Refactor as needed
- Remove unused code
- Update patterns
- Maintain standards

View File

@@ -0,0 +1,215 @@
# Operational Context
## System Runtime
### Production Environment
- Production URL: http://thrillwiki.com
- Django-based web application
- WSGI deployment
- Static file serving via staticfiles/
### Development Environment
- Command: python manage.py tailwind runserver
- Local development setup
- Debug mode configurations
- Development-specific settings
## Error Handling Patterns
### Application Errors
1. Django Error Pages
- 404.html for not found
- 500.html for server errors
- Custom error templates
- User-friendly messages
2. HTMX Error Handling
- Partial template errors
- Response status codes
- Error feedback in UI
- Graceful degradation
3. Form Validation
- Server-side validation
- Client-side checks
- Error message display
- Field-level feedback
### System Monitoring
1. Error Tracking
- Django logging
- Error reporting
- Performance monitoring
- User feedback collection
2. Performance Metrics
- Page load times
- Database query performance
- Media processing speed
- API response times
## Infrastructure Details
### File Storage
1. Media Handling
- Upload directory structure
- Media processing pipeline
- Storage backend configuration
- File type validation
2. Static Files
- Collected to staticfiles/
- CSS organization
- JavaScript structure
- Image optimization
### Database Operations
1. Query Optimization
- Indexed fields
- Efficient joins
- Cached queries
- Bulk operations
2. Data Integrity
- Foreign key constraints
- Validation rules
- Transaction management
- Backup procedures
### Caching Strategy
1. Template Caching
- Fragment caching
- Page caching
- Query caching
- Cache invalidation
2. Static Asset Caching
- Browser caching
- CDN configuration
- Cache headers
- Version control
## Performance Requirements
### Response Times
1. Page Load
- Initial load < 2s
- HTMX updates < 500ms
- API responses < 200ms
- Media loading optimized
2. Interactive Elements
- UI feedback < 100ms
- Form submission < 1s
- Search results < 500ms
- Media upload feedback
### Resource Usage
1. Server Resources
- CPU utilization
- Memory management
- Disk space monitoring
- Network bandwidth
2. Client Resources
- JavaScript performance
- DOM updates
- Memory usage
- Network requests
### Scalability Considerations
1. Database Scaling
- Connection pooling
- Query optimization
- Index management
- Partition strategy
2. Application Scaling
- Request handling
- Worker processes
- Cache distribution
- Load balancing
## Security Implementation
### Authentication
1. User Management
- Django authentication
- Session handling
- Password policies
- Account recovery
2. Access Control
- Permission system
- Role-based access
- View restrictions
- API security
### Data Protection
1. Input Validation
- Form validation
- File upload checks
- XSS prevention
- CSRF protection
2. Data Privacy
- User data handling
- Content visibility
- Access logging
- Data retention
## Maintenance Procedures
### Regular Tasks
1. System Updates
- Security patches
- Dependency updates
- Feature deployments
- Configuration changes
2. Monitoring
- Error tracking
- Performance metrics
- User activity
- Resource usage
### Backup Procedures
1. Data Backups
- Database dumps
- Media files
- Configuration
- User content
2. Recovery Plans
- Restore procedures
- Failover options
- Emergency contacts
- Incident response
## Integration Points
### External Services
1. Email Service
- Sending configuration
- Template management
- Queue handling
- Delivery tracking
2. Analytics
- Data collection
- Event tracking
- Performance monitoring
- User behavior analysis
### Internal Services
1. Media Processing
- Upload handling
- Image processing
- File validation
- Storage management
2. Search System
- Index management
- Query optimization
- Result ranking
- Filter implementation

View File

@@ -0,0 +1,86 @@
# Product Context
## Why We're Building This
ThrillWiki is a comprehensive platform for theme park enthusiasts to:
- Discover and explore theme parks and rides worldwide
- Share and access authentic reviews and experiences
- Track ride and park information
- Contribute to a moderated, high-quality knowledge base
## Core User Problems/Solutions
### For Park Enthusiasts
Problem: Difficulty finding accurate, comprehensive theme park information
Solution: Centralized, moderated platform with verified park/ride data
### For Reviewers
Problem: No dedicated platform for sharing detailed ride experiences
Solution: Structured review system with rich media support
### For Park Operators
Problem: Limited channels for authentic presence and information
Solution: Verified company profiles and official park information
## Key Workflows
1. Park Discovery & Information
- Browse parks by location
- View detailed park information
- Access operating hours and details
2. Ride Management
- Comprehensive ride database
- Technical specifications
- Historical information
- Designer attribution
3. Review System
- User-generated reviews
- Media attachments
- Rating system
- Moderation workflow
4. Content Moderation
- Submission review process
- Quality control
- Content verification
- User management
5. Location Services
- Geographic search
- Park proximity
- Regional categorization
## Product Direction and Priorities
### Current Focus
1. Content Quality
- Strong moderation system
- Verified information
- Rich media support
2. User Trust
- Review authenticity
- Company verification
- Expert contributions
3. Data Completeness
- Comprehensive park coverage
- Detailed ride information
- Historical records
### Future Priorities
1. Community Engagement
- Enhanced user profiles
- Contribution recognition
- Expert designations
2. Analytics Integration
- Usage patterns
- Content quality metrics
- User engagement tracking
3. Media Enhancement
- Improved image handling
- Video integration
- Virtual tours

View File

@@ -0,0 +1,183 @@
# Project Boundaries
## Technical Constraints
### Framework Constraints
1. Django Framework
- MVT architecture
- ORM limitations
- Template system bounds
- URL routing patterns
2. Frontend Technologies
- HTMX for dynamic updates
- AlpineJS for UI state
- No React/Vue allowed
- Progressive enhancement
### Development Constraints
1. Version Control
- Direct pushes to main branch
- Git-based workflow
- Detailed commit messages
- No branch strategy
2. Testing Requirements
- Test coverage maintenance
- Integration testing
- UI verification
- Performance testing
## Scale Requirements
### Data Scale
1. Content Volume
- Park entries
- Ride listings
- User reviews
- Media assets
- Historical records
2. User Scale
- Concurrent users
- Active sessions
- Authentication load
- Permission checks
### Performance Scale
1. Response Times
- Page load limits
- HTMX update speed
- API response times
- Media loading
2. Resource Usage
- Database connections
- Memory utilization
- CPU boundaries
- Storage limits
## Hard Limitations
### Technical Limitations
1. Frontend
- No client-side routing
- Server-side rendering required
- HTMX/AlpineJS only
- No additional JS frameworks
2. Backend
- Django ORM constraints
- Template rendering limits
- Request/response cycle
- Authentication flow
### Infrastructure Limitations
1. Deployment
- Single production URL
- Static file handling
- Media storage bounds
- Cache limitations
2. Processing
- Query complexity
- Batch processing
- Background tasks
- Concurrent operations
## Non-Negotiables
### Development Standards
1. Code Organization
- HTMX partials in model folders
- Clear file structure
- Documentation requirements
- Testing standards
2. Process Requirements
- Production URL usage
- Tailwind development server
- Git commit standards
- Documentation updates
### Technical Requirements
1. Frontend Implementation
- Server-side rendering
- HTMX for dynamics
- AlpineJS for state
- Tailwind for styling
2. Backend Implementation
- Django views
- Model organization
- URL structure
- Template hierarchy
### Quality Standards
1. Code Quality
- Test coverage
- Documentation
- Performance metrics
- Error handling
2. User Experience
- Response times
- Error feedback
- UI consistency
- Accessibility
## Implementation Boundaries
### Feature Limitations
1. Content Management
- Moderation workflow
- Media handling
- User permissions
- Version control
2. User Interaction
- Authentication flow
- Review system
- Rating limits
- Content creation
### Security Boundaries
1. Authentication
- Session management
- Password requirements
- Access control
- Role limitations
2. Data Protection
- Input validation
- Content filtering
- Privacy controls
- Data access
## Growth Limitations
### Scalability Bounds
1. Database Growth
- Table size limits
- Index boundaries
- Query complexity
- Connection pools
2. Content Expansion
- Storage capacity
- Media limitations
- Archive strategy
- Backup constraints
### Feature Expansion
1. Integration Limits
- External services
- API endpoints
- Third-party tools
- Plugin system
2. Functionality Bounds
- Core features
- Extension points
- Module limits
- Plugin architecture

View File

@@ -0,0 +1,143 @@
# System Patterns
## High-Level Architecture
### Backend Architecture
- Django-based MVT (Model-View-Template) architecture
- Modular app structure for domain separation
- HTMX for dynamic server-side rendering
- AlpineJS for client-side interactivity
### Core Apps
1. Parks & Rides
- parks/ - Park management
- rides/ - Ride information
- designers/ - Ride designer profiles
- companies/ - Park operator profiles
2. User Content
- reviews/ - User reviews
- media/ - Media management
- moderation/ - Content moderation
3. Supporting Systems
- accounts/ - User management
- analytics/ - Usage tracking
- location/ - Geographic services
- email_service/ - Communication
- history_tracking/ - Change tracking
## Core Technical Patterns
### Data Flow
1. Request Handling
- Django URL routing
- View processing
- HTMX partial updates
- Template rendering
2. Content Management
- Moderated submission flow
- Media processing pipeline
- Review validation system
- History tracking
3. User Interactions
- HTMX for dynamic updates
- AlpineJS for UI state
- Partial template loading
- Progressive enhancement
### Database Patterns
- Django ORM for data access
- Related models for complex relationships
- Signals for cross-model updates
- History tracking for changes
## Key Technical Decisions
### Frontend Strategy
1. Server-Side Rendering
- Django templates as base
- HTMX for dynamic updates
- Partial templates by model
- AlpineJS for client state
2. Styling
- Tailwind CSS
- Component-based design
- Responsive layouts
### Backend Organization
1. App Separation
- Domain-driven design
- Clear responsibility boundaries
- Modular functionality
- Reusable components
2. Code Structure
- Models for data definition
- Views for business logic
- Templates for presentation
- URLs for routing
- Signals for cross-cutting concerns
### Integration Patterns
1. External Services
- Email service integration
- Media storage handling
- Analytics tracking
- Location services
2. Internal Communication
- Django signals
- Context processors
- Middleware
- Template tags
## Data Flow Patterns
### Content Creation
1. User Input
- Form submission
- Media upload
- Review creation
- Park/ride updates
2. Processing
- Validation
- Moderation queue
- Media processing
- History tracking
3. Publication
- Approval workflow
- Public visibility
- Notification system
- Cache updates
### Query Patterns
1. Efficient Loading
- Select related
- Prefetch related
- Cached queries
- Optimized indexes
2. Search Operations
- Location-based queries
- Full-text search
- Filtered results
- Sorted listings
## Error Handling
- Django middleware
- Custom error pages
- Logging system
- User notifications
## Security Patterns
- Django authentication
- Permission mixins
- CSRF protection
- XSS prevention
- Input validation

254
cline_docs/techContext.md Normal file
View File

@@ -0,0 +1,254 @@
# Technical Context
## Core Technologies
### Backend Framework
1. Django
- MVT architecture
- ORM for data management
- Template system
- URL routing
- Form handling
- Authentication
- Admin interface
2. Python
- Version requirements
- Core libraries
- Package management
- Virtual environments
### Frontend Technologies
1. HTMX
- Dynamic updates
- Partial rendering
- Server-side processing
- Progressive enhancement
2. AlpineJS
- UI state management
- Component behavior
- Event handling
- DOM manipulation
3. Tailwind CSS
- Utility-first styling
- Component design
- Responsive layouts
- Custom configuration
## Integration Patterns
### Template System
1. Base Structure
- Base templates
- Partial templates by model
- Component reuse
- Template inheritance
2. HTMX Integration
- Partial updates
- Server triggers
- Event handling
- Response processing
### Data Flow
1. Model Layer
- Django ORM
- Database schema
- Relationships
- Validation rules
2. View Layer
- Class-based views
- Function views
- Mixins
- Decorators
3. Template Layer
- Django templates
- HTMX partials
- AlpineJS components
- Tailwind styles
## Key Libraries/Frameworks
### Django Extensions
1. Core Apps
- django.contrib.auth
- django.contrib.admin
- django.contrib.sessions
- django.contrib.messages
2. Third-Party
- django-tailwind
- django-htmx
- Additional dependencies
### Frontend Libraries
1. CSS Framework
- Tailwind CSS
- Custom plugins
- Theme configuration
- Utility classes
2. JavaScript
- AlpineJS core
- HTMX library
- Utility functions
- Custom components
## Infrastructure Choices
### Development Environment
1. Local Setup
- Python environment
- Django configuration
- Tailwind setup
- Development server
2. Tools
- VSCode
- Git
- Package managers
- Development utilities
### Production Environment
1. Hosting
- Server requirements
- Domain configuration
- SSL/TLS setup
- Static/media serving
2. Services
- Database hosting
- File storage
- Email service
- Monitoring tools
## Technical Constraints
### Development Rules
1. Code Standards
- Python style guide
- Django best practices
- Frontend patterns
- Documentation requirements
2. Process Requirements
- Git workflow
- Testing standards
- Review process
- Deployment steps
### Technology Limitations
1. Frontend
- HTMX/AlpineJS only
- No additional frameworks
- Browser compatibility
- Performance requirements
2. Backend
- Django version constraints
- Database limitations
- API restrictions
- Security requirements
## Development Environment
### Local Setup
1. Required Software
- Python
- pip
- Git
- Node.js (for Tailwind)
2. Configuration
- Environment variables
- Development settings
- Database setup
- Media handling
### Development Tools
1. Editor Setup
- VSCode configuration
- Extensions
- Linting
- Formatting
2. Testing Tools
- Django test runner
- Coverage tools
- Browser testing
- Performance testing
## Version Control
### Git Configuration
1. Repository Structure
- Main branch workflow
- Commit standards
- Push requirements
- Version tracking
2. Process
- Commit messages
- Code review
- Documentation
- Deployment
## Dependencies
### Python Packages
1. Core Dependencies
- Django
- Database adapters
- Utility packages
- Testing tools
2. Development Dependencies
- Debug tools
- Testing utilities
- Documentation generators
- Linting tools
### Frontend Dependencies
1. Required Packages
- Tailwind CSS
- AlpineJS
- HTMX
- Development tools
2. Build Tools
- Node.js
- npm/yarn
- Build scripts
- Asset compilation
## Documentation Standards
### Code Documentation
1. Python
- Docstrings
- Type hints
- Comments
- README files
2. Templates
- Component documentation
- HTMX attributes
- AlpineJS directives
- Style classes
### Technical Documentation
1. System Documentation
- Architecture overview
- Setup guides
- Deployment process
- Maintenance procedures
2. Developer Guides
- Getting started
- Best practices
- Common patterns
- Troubleshooting

View File

@@ -1,8 +1,5 @@
# Generated by Django 5.1.2 on 2024-10-28 20:17
# Generated by Django 5.1.3 on 2024-11-12 18:07
import django.db.models.deletion
import simple_history.models
from django.conf import settings
from django.db import migrations, models
@@ -10,96 +7,60 @@ class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
dependencies = []
operations = [
migrations.CreateModel(
name='Company',
name="Company",
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255)),
('slug', models.SlugField(max_length=255, unique=True)),
('headquarters', models.CharField(blank=True, max_length=255)),
('description', models.TextField(blank=True)),
('website', models.URLField(blank=True)),
('founded_date', models.DateField(blank=True, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=255)),
("slug", models.SlugField(max_length=255, unique=True)),
("website", models.URLField(blank=True)),
("headquarters", models.CharField(blank=True, max_length=255)),
("description", models.TextField(blank=True)),
("total_parks", models.IntegerField(default=0)),
("total_rides", models.IntegerField(default=0)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
],
options={
'verbose_name_plural': 'companies',
'ordering': ['name'],
"verbose_name_plural": "companies",
"ordering": ["name"],
},
),
migrations.CreateModel(
name='Manufacturer',
name="Manufacturer",
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255)),
('slug', models.SlugField(max_length=255, unique=True)),
('headquarters', models.CharField(blank=True, max_length=255)),
('description', models.TextField(blank=True)),
('website', models.URLField(blank=True)),
('founded_date', models.DateField(blank=True, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'ordering': ['name'],
},
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
migrations.CreateModel(
name='HistoricalCompany',
fields=[
('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')),
('name', models.CharField(max_length=255)),
('slug', models.SlugField(max_length=255)),
('headquarters', models.CharField(blank=True, max_length=255)),
('description', models.TextField(blank=True)),
('website', models.URLField(blank=True)),
('founded_date', models.DateField(blank=True, null=True)),
('created_at', models.DateTimeField(blank=True, editable=False)),
('updated_at', models.DateTimeField(blank=True, editable=False)),
('history_id', models.AutoField(primary_key=True, serialize=False)),
('history_date', models.DateTimeField(db_index=True)),
('history_change_reason', models.CharField(max_length=100, null=True)),
('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)),
('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'historical company',
'verbose_name_plural': 'historical companies',
'ordering': ('-history_date', '-history_id'),
'get_latest_by': ('history_date', 'history_id'),
},
bases=(simple_history.models.HistoricalChanges, models.Model),
),
migrations.CreateModel(
name='HistoricalManufacturer',
fields=[
('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')),
('name', models.CharField(max_length=255)),
('slug', models.SlugField(max_length=255)),
('headquarters', models.CharField(blank=True, max_length=255)),
('description', models.TextField(blank=True)),
('website', models.URLField(blank=True)),
('founded_date', models.DateField(blank=True, null=True)),
('created_at', models.DateTimeField(blank=True, editable=False)),
('updated_at', models.DateTimeField(blank=True, editable=False)),
('history_id', models.AutoField(primary_key=True, serialize=False)),
('history_date', models.DateTimeField(db_index=True)),
('history_change_reason', models.CharField(max_length=100, null=True)),
('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)),
('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
("name", models.CharField(max_length=255)),
("slug", models.SlugField(max_length=255, unique=True)),
("website", models.URLField(blank=True)),
("headquarters", models.CharField(blank=True, max_length=255)),
("description", models.TextField(blank=True)),
("total_rides", models.IntegerField(default=0)),
("total_roller_coasters", models.IntegerField(default=0)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': 'historical manufacturer',
'verbose_name_plural': 'historical manufacturers',
'ordering': ('-history_date', '-history_id'),
'get_latest_by': ('history_date', 'history_id'),
"ordering": ["name"],
},
bases=(simple_history.models.HistoricalChanges, models.Model),
),
]

View File

@@ -0,0 +1,28 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('companies', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='Designer',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255)),
('slug', models.SlugField(max_length=255, unique=True)),
('website', models.URLField(blank=True)),
('description', models.TextField(blank=True)),
('total_rides', models.IntegerField(default=0)),
('total_roller_coasters', models.IntegerField(default=0)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'ordering': ['name'],
},
),
]

View File

@@ -1,53 +0,0 @@
# Generated manually
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('companies', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='company',
name='total_parks',
field=models.PositiveIntegerField(default=0),
),
migrations.AddField(
model_name='company',
name='total_rides',
field=models.PositiveIntegerField(default=0),
),
migrations.AddField(
model_name='historicalcompany',
name='total_parks',
field=models.PositiveIntegerField(default=0),
),
migrations.AddField(
model_name='historicalcompany',
name='total_rides',
field=models.PositiveIntegerField(default=0),
),
migrations.AddField(
model_name='manufacturer',
name='total_rides',
field=models.PositiveIntegerField(default=0),
),
migrations.AddField(
model_name='manufacturer',
name='total_roller_coasters',
field=models.PositiveIntegerField(default=0),
),
migrations.AddField(
model_name='historicalmanufacturer',
name='total_rides',
field=models.PositiveIntegerField(default=0),
),
migrations.AddField(
model_name='historicalmanufacturer',
name='total_roller_coasters',
field=models.PositiveIntegerField(default=0),
),
]

View File

@@ -1,13 +0,0 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('companies', '0002_stats_fields'),
]
operations = [
migrations.RemoveField(
model_name='company',
name='total_parks',
),
]

View File

@@ -1,14 +0,0 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('companies', '0003_remove_total_parks'),
]
operations = [
migrations.AddField(
model_name='company',
name='total_parks',
field=models.PositiveIntegerField(default=0),
),
]

View File

@@ -88,3 +88,43 @@ class Manufacturer(models.Model):
return cls.objects.get(pk=historical.object_id), True
except (HistoricalSlug.DoesNotExist, cls.DoesNotExist):
raise cls.DoesNotExist()
class Designer(models.Model):
name = models.CharField(max_length=255)
slug = models.SlugField(max_length=255, unique=True)
website = models.URLField(blank=True)
description = models.TextField(blank=True)
total_rides = models.IntegerField(default=0)
total_roller_coasters = models.IntegerField(default=0)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
objects: ClassVar[models.Manager['Designer']]
class Meta:
ordering = ['name']
def __str__(self) -> str:
return self.name
def save(self, *args, **kwargs) -> None:
if not self.slug:
self.slug = slugify(self.name)
super().save(*args, **kwargs)
@classmethod
def get_by_slug(cls, slug: str) -> Tuple['Designer', bool]:
"""Get designer by slug, checking historical slugs if needed"""
try:
return cls.objects.get(slug=slug), False
except cls.DoesNotExist:
# Check historical slugs
from history_tracking.models import HistoricalSlug
try:
historical = HistoricalSlug.objects.get(
content_type__model='designer',
slug=slug
)
return cls.objects.get(pk=historical.object_id), True
except (HistoricalSlug.DoesNotExist, cls.DoesNotExist):
raise cls.DoesNotExist()

View File

@@ -1,4 +1,4 @@
# Generated by Django 5.1.2 on 2024-10-28 20:17
# Generated by Django 5.1.3 on 2024-11-12 18:07
import django.db.models.deletion
from django.db import migrations, models
@@ -9,23 +9,45 @@ class Migration(migrations.Migration):
initial = True
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
("contenttypes", "0002_remove_content_type_name"),
]
operations = [
migrations.CreateModel(
name='SlugHistory',
name="SlugHistory",
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('object_id', models.CharField(max_length=50)),
('old_slug', models.SlugField(max_length=200)),
('created_at', models.DateTimeField(auto_now_add=True)),
('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')),
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("object_id", models.CharField(max_length=50)),
("old_slug", models.SlugField(max_length=200)),
("created_at", models.DateTimeField(auto_now_add=True)),
(
"content_type",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="contenttypes.contenttype",
),
),
],
options={
'verbose_name_plural': 'Slug histories',
'ordering': ['-created_at'],
'indexes': [models.Index(fields=['content_type', 'object_id'], name='core_slughi_content_8bbf56_idx'), models.Index(fields=['old_slug'], name='core_slughi_old_slu_aaef7f_idx')],
"verbose_name_plural": "Slug histories",
"ordering": ["-created_at"],
"indexes": [
models.Index(
fields=["content_type", "object_id"],
name="core_slughi_content_8bbf56_idx",
),
models.Index(
fields=["old_slug"], name="core_slughi_old_slu_aaef7f_idx"
),
],
},
),
]

View File

@@ -1,4 +1,4 @@
# Generated by Django 5.1.2 on 2024-11-04 00:28
# Generated by Django 5.1.3 on 2024-11-12 18:07
import django.db.models.deletion
import simple_history.models

View File

@@ -1,4 +1,4 @@
# Generated by Django 5.1.2 on 2024-10-28 20:17
# Generated by Django 5.1.3 on 2024-11-12 18:07
import django.db.models.deletion
from django.db import migrations, models
@@ -9,25 +9,44 @@ class Migration(migrations.Migration):
initial = True
dependencies = [
('sites', '0002_alter_domain_unique'),
("sites", "0002_alter_domain_unique"),
]
operations = [
migrations.CreateModel(
name='EmailConfiguration',
name="EmailConfiguration",
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('api_key', models.CharField(max_length=255)),
('from_email', models.EmailField(max_length=254)),
('from_name', models.CharField(help_text='The name that will appear in the From field of emails', max_length=255)),
('reply_to', models.EmailField(max_length=254)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('site', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='sites.site')),
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("api_key", models.CharField(max_length=255)),
("from_email", models.EmailField(max_length=254)),
(
"from_name",
models.CharField(
help_text="The name that will appear in the From field of emails",
max_length=255,
),
),
("reply_to", models.EmailField(max_length=254)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"site",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="sites.site"
),
),
],
options={
'verbose_name': 'Email Configuration',
'verbose_name_plural': 'Email Configurations',
"verbose_name": "Email Configuration",
"verbose_name_plural": "Email Configurations",
},
),
]

View File

@@ -1,9 +1,6 @@
# Generated by Django 5.1.2 on 2024-11-03 19:59
# Generated by Django 5.1.3 on 2024-11-12 18:07
import django.db.models.deletion
import history_tracking.mixins
import simple_history.models
from django.conf import settings
from django.db import migrations, models
@@ -12,12 +9,12 @@ class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("contenttypes", "0002_remove_content_type_name"),
]
operations = [
migrations.CreateModel(
name="Park",
name="HistoricalSlug",
fields=[
(
"id",
@@ -28,49 +25,26 @@ class Migration(migrations.Migration):
verbose_name="ID",
),
),
("name", models.CharField(max_length=200)),
],
),
migrations.CreateModel(
name="HistoricalPark",
fields=[
("object_id", models.PositiveIntegerField()),
("slug", models.SlugField(max_length=255)),
("created_at", models.DateTimeField(auto_now_add=True)),
(
"id",
models.BigIntegerField(
auto_created=True, blank=True, db_index=True, verbose_name="ID"
),
),
("name", models.CharField(max_length=200)),
("history_id", models.AutoField(primary_key=True, serialize=False)),
("history_date", models.DateTimeField(db_index=True)),
("history_change_reason", models.CharField(max_length=100, null=True)),
(
"history_type",
models.CharField(
choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")],
max_length=1,
),
),
(
"history_user",
"content_type",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="+",
to=settings.AUTH_USER_MODEL,
on_delete=django.db.models.deletion.CASCADE,
to="contenttypes.contenttype",
),
),
],
options={
"verbose_name": "historical park",
"verbose_name_plural": "historical parks",
"ordering": ("-history_date", "-history_id"),
"get_latest_by": ("history_date", "history_id"),
},
bases=(
history_tracking.mixins.HistoricalChangeMixin,
simple_history.models.HistoricalChanges,
models.Model,
"indexes": [
models.Index(
fields=["content_type", "object_id"],
name="history_tra_content_63013c_idx",
),
models.Index(fields=["slug"], name="history_tra_slug_f843aa_idx"),
],
"unique_together": {("content_type", "slug")},
},
),
]

View File

@@ -1,23 +0,0 @@
# Generated by Django 5.1.2 on 2024-11-04 00:17
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("history_tracking", "0001_initial"),
]
operations = [
migrations.RemoveField(
model_name="historicalpark",
name="history_user",
),
migrations.DeleteModel(
name="Park",
),
migrations.DeleteModel(
name="HistoricalPark",
),
]

View File

@@ -1,54 +0,0 @@
# Generated by Django 5.1.2 on 2024-11-05 20:44
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("contenttypes", "0002_remove_content_type_name"),
(
"history_tracking",
"0002_remove_historicalpark_history_user_delete_park_and_more",
),
]
operations = [
migrations.CreateModel(
name="HistoricalSlug",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("object_id", models.PositiveIntegerField()),
("slug", models.SlugField(max_length=255)),
("created_at", models.DateTimeField(auto_now_add=True)),
(
"content_type",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="contenttypes.contenttype",
),
),
],
options={
"indexes": [
models.Index(
fields=["content_type", "object_id"],
name="history_tra_content_63013c_idx",
),
models.Index(fields=["slug"], name="history_tra_slug_f843aa_idx"),
],
"unique_together": {("content_type", "slug")},
},
),
]

View File

@@ -1,12 +1,30 @@
# history_tracking/mixins.py
from django.db import models
from django.conf import settings
class HistoricalChangeMixin(models.Model):
"""Mixin for historical models to track changes"""
id = models.BigIntegerField(db_index=True, auto_created=True, blank=True)
history_date = models.DateTimeField()
history_id = models.AutoField(primary_key=True)
history_type = models.CharField(max_length=1)
history_user = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
on_delete=models.SET_NULL,
related_name='+'
)
history_change_reason = models.CharField(max_length=100, null=True)
class Meta:
abstract = True
ordering = ['-history_date', '-history_id']
class HistoricalChangeMixin:
@property
def prev_record(self):
"""Get the previous record for this instance"""
try:
return type(self).objects.filter(
return self.__class__.objects.filter(
history_date__lt=self.history_date,
id=self.id
).order_by('-history_date').first()
@@ -28,12 +46,29 @@ class HistoricalChangeMixin:
"history_user_id",
"history_change_reason",
"history_type",
"id",
"_state",
"_history_user_cache"
] and not field.startswith("_"):
try:
old_value = getattr(prev_record, field)
new_value = getattr(self, field)
if old_value != new_value:
changes[field] = {"old": old_value, "new": new_value}
changes[field] = {"old": str(old_value), "new": str(new_value)}
except AttributeError:
continue
return changes
@property
def history_user_display(self):
"""Get a display name for the history user"""
if hasattr(self, 'history_user') and self.history_user:
return str(self.history_user)
return None
def get_instance(self):
"""Get the model instance this history record represents"""
try:
return self.__class__.objects.get(id=self.id)
except self.__class__.DoesNotExist:
return None

View File

@@ -5,12 +5,17 @@ from django.contrib.contenttypes.fields import GenericForeignKey
from simple_history.models import HistoricalRecords
from .mixins import HistoricalChangeMixin
from typing import Any, Type, TypeVar, cast
from django.db.models import QuerySet
T = TypeVar('T', bound=models.Model)
class HistoricalModel(models.Model):
"""Abstract base class for models with history tracking"""
history: HistoricalRecords = HistoricalRecords(inherit=True)
id = models.BigAutoField(primary_key=True)
history: HistoricalRecords = HistoricalRecords(
inherit=True,
bases=(HistoricalChangeMixin,)
)
class Meta:
abstract = True
@@ -20,6 +25,11 @@ class HistoricalModel(models.Model):
"""Get the history model class"""
return cast(Type[T], self.history.model) # type: ignore
def get_history(self) -> QuerySet:
"""Get all history records for this instance"""
model = self._history_model
return model.objects.filter(id=self.pk).order_by('-history_date')
class HistoricalSlug(models.Model):
"""Track historical slugs for models"""
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)

View File

@@ -1,5 +1,6 @@
# Generated by Django 5.1.2 on 2024-11-02 23:28
# Generated by Django 5.1.3 on 2024-11-12 18:07
import django.contrib.gis.db.models.fields
import django.core.validators
import django.db.models.deletion
import simple_history.models
@@ -44,9 +45,11 @@ class Migration(migrations.Migration):
(
"latitude",
models.DecimalField(
blank=True,
decimal_places=6,
help_text="Latitude coordinate",
help_text="Latitude coordinate (legacy field)",
max_digits=9,
null=True,
validators=[
django.core.validators.MinValueValidator(-90),
django.core.validators.MaxValueValidator(90),
@@ -56,25 +59,42 @@ class Migration(migrations.Migration):
(
"longitude",
models.DecimalField(
blank=True,
decimal_places=6,
help_text="Longitude coordinate",
help_text="Longitude coordinate (legacy field)",
max_digits=9,
null=True,
validators=[
django.core.validators.MinValueValidator(-180),
django.core.validators.MaxValueValidator(180),
],
),
),
("street_address", models.CharField(blank=True, max_length=255)),
("city", models.CharField(max_length=100)),
(
"point",
django.contrib.gis.db.models.fields.PointField(
blank=True,
help_text="Geographic coordinates as a Point",
null=True,
srid=4326,
),
),
(
"street_address",
models.CharField(blank=True, max_length=255, null=True),
),
("city", models.CharField(blank=True, max_length=100, null=True)),
(
"state",
models.CharField(
blank=True, help_text="State/Region/Province", max_length=100
blank=True,
help_text="State/Region/Province",
max_length=100,
null=True,
),
),
("country", models.CharField(max_length=100)),
("postal_code", models.CharField(blank=True, max_length=20)),
("country", models.CharField(blank=True, max_length=100, null=True)),
("postal_code", models.CharField(blank=True, max_length=20, null=True)),
("created_at", models.DateTimeField(blank=True, editable=False)),
("updated_at", models.DateTimeField(blank=True, editable=False)),
("history_id", models.AutoField(primary_key=True, serialize=False)),
@@ -146,9 +166,11 @@ class Migration(migrations.Migration):
(
"latitude",
models.DecimalField(
blank=True,
decimal_places=6,
help_text="Latitude coordinate",
help_text="Latitude coordinate (legacy field)",
max_digits=9,
null=True,
validators=[
django.core.validators.MinValueValidator(-90),
django.core.validators.MaxValueValidator(90),
@@ -158,25 +180,42 @@ class Migration(migrations.Migration):
(
"longitude",
models.DecimalField(
blank=True,
decimal_places=6,
help_text="Longitude coordinate",
help_text="Longitude coordinate (legacy field)",
max_digits=9,
null=True,
validators=[
django.core.validators.MinValueValidator(-180),
django.core.validators.MaxValueValidator(180),
],
),
),
("street_address", models.CharField(blank=True, max_length=255)),
("city", models.CharField(max_length=100)),
(
"point",
django.contrib.gis.db.models.fields.PointField(
blank=True,
help_text="Geographic coordinates as a Point",
null=True,
srid=4326,
),
),
(
"street_address",
models.CharField(blank=True, max_length=255, null=True),
),
("city", models.CharField(blank=True, max_length=100, null=True)),
(
"state",
models.CharField(
blank=True, help_text="State/Region/Province", max_length=100
blank=True,
help_text="State/Region/Province",
max_length=100,
null=True,
),
),
("country", models.CharField(max_length=100)),
("postal_code", models.CharField(blank=True, max_length=20)),
("country", models.CharField(blank=True, max_length=100, null=True)),
("postal_code", models.CharField(blank=True, max_length=20, null=True)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
@@ -194,10 +233,6 @@ class Migration(migrations.Migration):
fields=["content_type", "object_id"],
name="location_lo_content_9ee1bd_idx",
),
models.Index(
fields=["latitude", "longitude"],
name="location_lo_latitud_7045c4_idx",
),
models.Index(fields=["city"], name="location_lo_city_99f908_idx"),
models.Index(
fields=["country"], name="location_lo_country_b75eba_idx"

View File

@@ -1,84 +0,0 @@
# Generated by Django 5.1.2 on 2024-11-02 23:34
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("location", "0001_initial"),
]
operations = [
migrations.AlterField(
model_name="historicallocation",
name="city",
field=models.CharField(blank=True, max_length=100),
),
migrations.AlterField(
model_name="historicallocation",
name="latitude",
field=models.DecimalField(
blank=True,
decimal_places=6,
help_text="Latitude coordinate",
max_digits=9,
null=True,
validators=[
django.core.validators.MinValueValidator(-90),
django.core.validators.MaxValueValidator(90),
],
),
),
migrations.AlterField(
model_name="historicallocation",
name="longitude",
field=models.DecimalField(
blank=True,
decimal_places=6,
help_text="Longitude coordinate",
max_digits=9,
null=True,
validators=[
django.core.validators.MinValueValidator(-180),
django.core.validators.MaxValueValidator(180),
],
),
),
migrations.AlterField(
model_name="location",
name="city",
field=models.CharField(blank=True, max_length=100),
),
migrations.AlterField(
model_name="location",
name="latitude",
field=models.DecimalField(
blank=True,
decimal_places=6,
help_text="Latitude coordinate",
max_digits=9,
null=True,
validators=[
django.core.validators.MinValueValidator(-90),
django.core.validators.MaxValueValidator(90),
],
),
),
migrations.AlterField(
model_name="location",
name="longitude",
field=models.DecimalField(
blank=True,
decimal_places=6,
help_text="Longitude coordinate",
max_digits=9,
null=True,
validators=[
django.core.validators.MinValueValidator(-180),
django.core.validators.MaxValueValidator(180),
],
),
),
]

View File

@@ -1,67 +0,0 @@
# Generated by Django 5.1.2 on 2024-11-02 23:35
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("location", "0002_alter_historicallocation_city_and_more"),
]
operations = [
migrations.AlterField(
model_name="historicallocation",
name="city",
field=models.CharField(blank=True, max_length=100, null=True),
),
migrations.AlterField(
model_name="historicallocation",
name="country",
field=models.CharField(blank=True, max_length=100, null=True),
),
migrations.AlterField(
model_name="historicallocation",
name="postal_code",
field=models.CharField(blank=True, max_length=20, null=True),
),
migrations.AlterField(
model_name="historicallocation",
name="state",
field=models.CharField(
blank=True, help_text="State/Region/Province", max_length=100, null=True
),
),
migrations.AlterField(
model_name="historicallocation",
name="street_address",
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AlterField(
model_name="location",
name="city",
field=models.CharField(blank=True, max_length=100, null=True),
),
migrations.AlterField(
model_name="location",
name="country",
field=models.CharField(blank=True, max_length=100, null=True),
),
migrations.AlterField(
model_name="location",
name="postal_code",
field=models.CharField(blank=True, max_length=20, null=True),
),
migrations.AlterField(
model_name="location",
name="state",
field=models.CharField(
blank=True, help_text="State/Region/Province", max_length=100, null=True
),
),
migrations.AlterField(
model_name="location",
name="street_address",
field=models.CharField(blank=True, max_length=255, null=True),
),
]

View File

@@ -1,27 +0,0 @@
# Generated by Django 5.1.2 on 2024-11-04 22:30
import django.contrib.gis.db.models.fields
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("location", "0003_alter_historicallocation_city_and_more"),
]
operations = [
migrations.AddField(
model_name="location",
name="point",
field=django.contrib.gis.db.models.fields.PointField(
blank=True,
help_text="Geographic coordinates as a Point",
null=True,
srid=4326,
),
),
migrations.DeleteModel(
name="HistoricalLocation",
),
]

View File

@@ -1,52 +0,0 @@
# Generated by Django 5.1.2 on 2024-11-04 22:21
from django.db import migrations, transaction
from django.contrib.gis.geos import Point
def forwards_func(apps, schema_editor):
"""Convert existing lat/lon coordinates to points"""
Location = apps.get_model("location", "Location")
db_alias = schema_editor.connection.alias
# Update all locations with points based on existing lat/lon
with transaction.atomic():
for location in Location.objects.using(db_alias).all():
if location.latitude is not None and location.longitude is not None:
try:
location.point = Point(
float(location.longitude), # x coordinate (longitude)
float(location.latitude), # y coordinate (latitude)
srid=4326 # WGS84 coordinate system
)
location.save(update_fields=['point'])
except (ValueError, TypeError):
print(f"Warning: Could not convert coordinates for location {location.id}")
continue
def reverse_func(apps, schema_editor):
"""Convert points back to lat/lon coordinates"""
Location = apps.get_model("location", "Location")
db_alias = schema_editor.connection.alias
# Update all locations with lat/lon based on points
with transaction.atomic():
for location in Location.objects.using(db_alias).all():
if location.point:
try:
location.latitude = location.point.y
location.longitude = location.point.x
location.point = None
location.save(update_fields=['latitude', 'longitude', 'point'])
except (ValueError, TypeError, AttributeError):
print(f"Warning: Could not convert point back to coordinates for location {location.id}")
continue
class Migration(migrations.Migration):
dependencies = [
('location', '0004_add_point_field'),
]
operations = [
migrations.RunPython(forwards_func, reverse_func, atomic=True),
]

View File

@@ -1,174 +0,0 @@
# Generated by Django 5.1.2 on 2024-11-04 22:32
import django.contrib.gis.db.models.fields
import django.core.validators
import django.db.models.deletion
import simple_history.models
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("contenttypes", "0002_remove_content_type_name"),
("location", "0005_convert_coordinates_to_points"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="HistoricalLocation",
fields=[
(
"id",
models.BigIntegerField(
auto_created=True, blank=True, db_index=True, verbose_name="ID"
),
),
("object_id", models.PositiveIntegerField()),
(
"name",
models.CharField(
help_text="Name of the location (e.g. business name, landmark)",
max_length=255,
),
),
(
"location_type",
models.CharField(
help_text="Type of location (e.g. business, landmark, address)",
max_length=50,
),
),
(
"latitude",
models.DecimalField(
blank=True,
decimal_places=6,
help_text="Latitude coordinate (legacy field)",
max_digits=9,
null=True,
validators=[
django.core.validators.MinValueValidator(-90),
django.core.validators.MaxValueValidator(90),
],
),
),
(
"longitude",
models.DecimalField(
blank=True,
decimal_places=6,
help_text="Longitude coordinate (legacy field)",
max_digits=9,
null=True,
validators=[
django.core.validators.MinValueValidator(-180),
django.core.validators.MaxValueValidator(180),
],
),
),
(
"point",
django.contrib.gis.db.models.fields.PointField(
blank=True,
help_text="Geographic coordinates as a Point",
null=True,
srid=4326,
),
),
(
"street_address",
models.CharField(blank=True, max_length=255, null=True),
),
("city", models.CharField(blank=True, max_length=100, null=True)),
(
"state",
models.CharField(
blank=True,
help_text="State/Region/Province",
max_length=100,
null=True,
),
),
("country", models.CharField(blank=True, max_length=100, null=True)),
("postal_code", models.CharField(blank=True, max_length=20, null=True)),
("created_at", models.DateTimeField(blank=True, editable=False)),
("updated_at", models.DateTimeField(blank=True, editable=False)),
("history_id", models.AutoField(primary_key=True, serialize=False)),
("history_date", models.DateTimeField(db_index=True)),
("history_change_reason", models.CharField(max_length=100, null=True)),
(
"history_type",
models.CharField(
choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")],
max_length=1,
),
),
],
options={
"verbose_name": "historical location",
"verbose_name_plural": "historical locations",
"ordering": ("-history_date", "-history_id"),
"get_latest_by": ("history_date", "history_id"),
},
bases=(simple_history.models.HistoricalChanges, models.Model),
),
migrations.RemoveIndex(
model_name="location",
name="location_lo_latitud_7045c4_idx",
),
migrations.AlterField(
model_name="location",
name="latitude",
field=models.DecimalField(
blank=True,
decimal_places=6,
help_text="Latitude coordinate (legacy field)",
max_digits=9,
null=True,
validators=[
django.core.validators.MinValueValidator(-90),
django.core.validators.MaxValueValidator(90),
],
),
),
migrations.AlterField(
model_name="location",
name="longitude",
field=models.DecimalField(
blank=True,
decimal_places=6,
help_text="Longitude coordinate (legacy field)",
max_digits=9,
null=True,
validators=[
django.core.validators.MinValueValidator(-180),
django.core.validators.MaxValueValidator(180),
],
),
),
migrations.AddField(
model_name="historicallocation",
name="content_type",
field=models.ForeignKey(
blank=True,
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
to="contenttypes.contenttype",
),
),
migrations.AddField(
model_name="historicallocation",
name="history_user",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="+",
to=settings.AUTH_USER_MODEL,
),
),
]

View File

@@ -1,7 +1,9 @@
# Generated by Django 5.1.2 on 2024-10-28 20:17
# Generated by Django 5.1.3 on 2024-11-12 18:07
import django.db.models.deletion
import media.models
import media.storage
from django.conf import settings
from django.db import migrations, models
@@ -10,26 +12,64 @@ class Migration(migrations.Migration):
initial = True
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
("contenttypes", "0002_remove_content_type_name"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Photo',
name="Photo",
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('image', models.ImageField(upload_to=media.models.photo_upload_path)),
('caption', models.CharField(blank=True, max_length=255)),
('alt_text', models.CharField(blank=True, max_length=255)),
('is_primary', models.BooleanField(default=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('object_id', models.PositiveIntegerField()),
('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')),
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"image",
models.ImageField(
max_length=255,
storage=media.storage.MediaStorage(),
upload_to=media.models.photo_upload_path,
),
),
("caption", models.CharField(blank=True, max_length=255)),
("alt_text", models.CharField(blank=True, max_length=255)),
("is_primary", models.BooleanField(default=False)),
("is_approved", models.BooleanField(default=False)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("date_taken", models.DateTimeField(blank=True, null=True)),
("object_id", models.PositiveIntegerField()),
(
"content_type",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="contenttypes.contenttype",
),
),
(
"uploaded_by",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="uploaded_photos",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
'ordering': ['-is_primary', '-created_at'],
'indexes': [models.Index(fields=['content_type', 'object_id'], name='media_photo_content_0187f5_idx')],
"ordering": ["-is_primary", "-created_at"],
"indexes": [
models.Index(
fields=["content_type", "object_id"],
name="media_photo_content_0187f5_idx",
)
],
},
),
]

View File

@@ -1,26 +0,0 @@
# Generated by Django 5.1.2 on 2024-11-01 00:24
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("media", "0001_initial"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name="photo",
name="uploaded_by",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="uploaded_photos",
to=settings.AUTH_USER_MODEL,
),
),
]

View File

@@ -1,69 +0,0 @@
from django.db import migrations, models
import os
from django.db import transaction
def normalize_filenames(apps, schema_editor):
Photo = apps.get_model('media', 'Photo')
db_alias = schema_editor.connection.alias
# Get all photos
photos = Photo.objects.using(db_alias).all()
for photo in photos:
try:
with transaction.atomic():
# Get content type model name
content_type_model = photo.content_type.model
# Get current filename and extension
old_path = photo.image.name
_, ext = os.path.splitext(old_path)
if not ext:
ext = '.jpg' # Default to .jpg if no extension
ext = ext.lower()
# Get the photo number (based on creation order)
photo_number = Photo.objects.using(db_alias).filter(
content_type=photo.content_type,
object_id=photo.object_id,
created_at__lte=photo.created_at
).count()
# Extract identifier from current path
parts = old_path.split('/')
if len(parts) >= 2:
identifier = parts[1] # e.g., "alton-towers" from "park/alton-towers/..."
# Create new normalized filename
new_filename = f"{identifier}_{photo_number}{ext}"
new_path = f"{content_type_model}/{identifier}/{new_filename}"
# Update the image field if path would change
if old_path != new_path:
photo.image.name = new_path
photo.save(using=db_alias)
except Exception as e:
print(f"Error normalizing photo {photo.id}: {str(e)}")
# Continue with next photo even if this one fails
continue
def reverse_normalize(apps, schema_editor):
# No reverse operation needed since we're just renaming files
pass
class Migration(migrations.Migration):
dependencies = [
('media', '0002_photo_uploaded_by'),
]
operations = [
# First increase the field length
migrations.AlterField(
model_name='photo',
name='image',
field=models.ImageField(max_length=255, upload_to='photos'),
),
# Then normalize the filenames
migrations.RunPython(normalize_filenames, reverse_normalize),
]

View File

@@ -1,10 +0,0 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('media', '0003_update_photo_field_and_normalize'),
]
operations = [
# No schema changes needed, just need to trigger the new upload_to path
]

View File

@@ -1,24 +0,0 @@
# Generated by Django 5.1.2 on 2024-11-02 23:30
import media.models
import media.storage
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("media", "0004_update_photo_paths"),
]
operations = [
migrations.AlterField(
model_name="photo",
name="image",
field=models.ImageField(
max_length=255,
storage=media.storage.MediaStorage(),
upload_to=media.models.photo_upload_path,
),
),
]

View File

@@ -1,18 +0,0 @@
# Generated by Django 5.1.2 on 2024-11-05 03:55
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("media", "0005_alter_photo_image"),
]
operations = [
migrations.AddField(
model_name="photo",
name="is_approved",
field=models.BooleanField(default=False),
),
]

View File

@@ -1,18 +0,0 @@
# Generated by Django 5.1.2 on 2024-11-05 18:35
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("media", "0006_photo_is_approved"),
]
operations = [
migrations.AddField(
model_name="photo",
name="date_taken",
field=models.DateTimeField(blank=True, null=True),
),
]

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 B

View File

@@ -9,8 +9,18 @@ def moderation_access(request):
if request.user.is_authenticated:
context['user_role'] = request.user.role
context['has_moderation_access'] = request.user.role in ['MODERATOR', 'ADMIN', 'SUPERUSER']
context['has_admin_access'] = request.user.role in ['ADMIN', 'SUPERUSER']
context['has_superuser_access'] = request.user.role == 'SUPERUSER'
# Check both role-based and Django's built-in superuser status
context['has_moderation_access'] = (
request.user.role in ['MODERATOR', 'ADMIN', 'SUPERUSER'] or
request.user.is_superuser
)
context['has_admin_access'] = (
request.user.role in ['ADMIN', 'SUPERUSER'] or
request.user.is_superuser
)
context['has_superuser_access'] = (
request.user.role == 'SUPERUSER' or
request.user.is_superuser
)
return context

View File

View File

@@ -0,0 +1,228 @@
from django.core.management.base import BaseCommand
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from django.core.files.uploadedfile import SimpleUploadedFile
from django.utils import timezone
from moderation.models import EditSubmission, PhotoSubmission
from parks.models import Park
from rides.models import Ride
from datetime import date, timedelta
User = get_user_model()
class Command(BaseCommand):
help = 'Seeds test submissions for moderation dashboard'
def handle(self, *args, **kwargs):
# Ensure we have a test user
user, created = User.objects.get_or_create(
username='test_user',
email='test@example.com'
)
if created:
user.set_password('testpass123')
user.save()
self.stdout.write(self.style.SUCCESS('Created test user'))
# Get content types
park_ct = ContentType.objects.get_for_model(Park)
ride_ct = ContentType.objects.get_for_model(Ride)
# Create test park for edit submissions
test_park, created = Park.objects.get_or_create(
name='Test Park',
defaults={
'description': 'A test theme park located in Orlando, Florida',
'status': 'OPERATING',
'operating_season': 'Year-round',
'size_acres': 100.50,
'website': 'https://testpark.example.com'
}
)
# Create test ride for edit submissions
test_ride, created = Ride.objects.get_or_create(
name='Test Coaster',
park=test_park,
defaults={
'description': 'A thrilling steel roller coaster with multiple inversions',
'status': 'OPERATING',
'category': 'RC',
'capacity_per_hour': 1200,
'ride_duration_seconds': 180,
'min_height_in': 48,
'opening_date': date(2020, 6, 15)
}
)
# Create EditSubmissions
# New park creation with detailed information
EditSubmission.objects.create(
user=user,
content_type=park_ct,
submission_type='CREATE',
changes={
'name': 'Adventure World Orlando',
'description': ('A brand new theme park coming to Orlando featuring five uniquely themed lands: '
'Future Frontier, Ancient Mysteries, Ocean Depths, Sky Kingdom, and Fantasy Forest. '
'The park will feature state-of-the-art attractions including 3 roller coasters, '
'4 dark rides, and multiple family attractions in each themed area.'),
'status': 'UNDER_CONSTRUCTION',
'opening_date': '2024-06-01',
'operating_season': 'Year-round with extended hours during summer and holidays',
'size_acres': 250.75,
'website': 'https://adventureworld.example.com',
'location': {
'street_address': '1234 Theme Park Way',
'city': 'Orlando',
'state': 'Florida',
'country': 'United States',
'postal_code': '32819',
'latitude': '28.538336',
'longitude': '-81.379234'
}
},
reason=('Submitting new theme park details based on official press release and construction permits. '
'The park has begun vertical construction and has announced its opening date.'),
source=('Official press release: https://adventureworld.example.com/press/announcement\n'
'Construction permits: Orange County Building Department #2023-12345'),
status='PENDING'
)
# Existing park edit with comprehensive updates
EditSubmission.objects.create(
user=user,
content_type=park_ct,
object_id=test_park.id,
submission_type='EDIT',
changes={
'description': ('A world-class theme park featuring 12 uniquely themed areas and over 50 attractions. '
'Recent expansion added the new "Cosmic Adventures" area with 2 roller coasters and '
'3 family attractions. The park now offers enhanced dining options and night-time '
'spectacular "Starlight Dreams".'),
'status': 'OPERATING',
'website': 'https://testpark.example.com',
'size_acres': 120.25,
'operating_season': ('Year-round with extended hours (9AM-11PM) during summer. '
'Special events during Halloween and Christmas seasons.'),
'location': {
'street_address': '5678 Park Boulevard',
'city': 'Orlando',
'state': 'Florida',
'country': 'United States',
'postal_code': '32830',
'latitude': '28.538336',
'longitude': '-81.379234'
}
},
reason=('Updating park information to reflect recent expansion and operational changes. '
'The new Cosmic Adventures area opened last month and operating hours have been extended.'),
source=('Park press release: https://testpark.example.com/news/expansion\n'
'Official park map: https://testpark.example.com/map\n'
'Personal visit and photos from opening day of new area'),
status='PENDING'
)
# New ride creation with detailed specifications
EditSubmission.objects.create(
user=user,
content_type=ride_ct,
submission_type='CREATE',
changes={
'name': 'Thunderbolt: The Ultimate Launch Coaster',
'park': test_park.id,
'description': ('A cutting-edge steel launch coaster featuring the world\'s tallest inversion (160 ft) '
'and fastest launch acceleration (0-80 mph in 2 seconds). The ride features a unique '
'triple launch system, 5 inversions including a zero-g roll and cobra roll, and a '
'first-of-its-kind vertical helix element. Total track length is 4,500 feet with a '
'maximum height of 375 feet.'),
'status': 'UNDER_CONSTRUCTION',
'category': 'RC',
'opening_date': '2024-07-01',
'capacity_per_hour': 1400,
'ride_duration_seconds': 210,
'min_height_in': 52,
'manufacturer': 1, # Assuming manufacturer ID
'park_area': 1, # Assuming park area ID
'stats': {
'height_ft': 375,
'length_ft': 4500,
'speed_mph': 80,
'inversions': 5,
'launch_type': 'LSM',
'track_material': 'STEEL',
'roller_coaster_type': 'SITDOWN',
'trains_count': 3,
'cars_per_train': 6,
'seats_per_car': 4
}
},
reason=('Submitting details for the new flagship roller coaster announced by the park. '
'Construction has begun and track pieces are arriving on site.'),
source=('Official announcement: https://testpark.example.com/thunderbolt\n'
'Construction photos: https://coasterfan.com/thunderbolt-construction\n'
'Manufacturer specifications sheet'),
status='PENDING'
)
# Existing ride edit with technical updates
EditSubmission.objects.create(
user=user,
content_type=ride_ct,
object_id=test_ride.id,
submission_type='EDIT',
changes={
'description': ('A high-speed steel roller coaster featuring 4 inversions and a unique '
'dual-loading station system. Recent upgrades include new magnetic braking '
'system and enhanced on-board audio experience.'),
'status': 'OPERATING',
'capacity_per_hour': 1500, # Increased after station upgrades
'ride_duration_seconds': 185,
'min_height_in': 48,
'max_height_in': 80,
'stats': {
'trains_count': 3,
'cars_per_train': 8,
'seats_per_car': 4
}
},
reason=('Updating ride information to reflect recent upgrades including new braking system, '
'audio system, and increased capacity due to improved loading efficiency.'),
source=('Park operations manual\n'
'Maintenance records\n'
'Personal observation and timing of new ride cycle'),
status='PENDING'
)
# Create PhotoSubmissions with detailed captions
# Park photo submission
image_data = b'GIF87a\x01\x00\x01\x00\x80\x01\x00\x00\x00\x00ccc,\x00\x00\x00\x00\x01\x00\x01\x00\x00\x02\x02D\x01\x00;'
dummy_image = SimpleUploadedFile('park_entrance.gif', image_data, content_type='image/gif')
PhotoSubmission.objects.create(
user=user,
content_type=park_ct,
object_id=test_park.id,
photo=dummy_image,
caption=('Main entrance plaza of Test Park showing the newly installed digital display board '
'and renovated ticketing area. Photo taken during morning park opening.'),
date_taken=date(2024, 1, 15),
status='PENDING'
)
# Ride photo submission
dummy_image2 = SimpleUploadedFile('coaster_track.gif', image_data, content_type='image/gif')
PhotoSubmission.objects.create(
user=user,
content_type=ride_ct,
object_id=test_ride.id,
photo=dummy_image2,
caption=('Test Coaster\'s first drop and loop element showing the new paint scheme. '
'Photo taken from the guest pathway near Station Alpha.'),
date_taken=date(2024, 1, 20),
status='PENDING'
)
self.stdout.write(self.style.SUCCESS('Successfully seeded test submissions'))

View File

@@ -1,4 +1,4 @@
# Generated by Django 5.1.2 on 2024-10-30 00:41
# Generated by Django 5.1.3 on 2024-11-12 18:07
import django.db.models.deletion
from django.conf import settings
@@ -27,38 +27,48 @@ class Migration(migrations.Migration):
verbose_name="ID",
),
),
("object_id", models.PositiveIntegerField()),
("object_id", models.PositiveIntegerField(blank=True, null=True)),
(
"submission_type",
models.CharField(
choices=[("EDIT", "Edit Existing"), ("CREATE", "Create New")],
default="EDIT",
max_length=10,
),
),
(
"changes",
models.JSONField(
help_text="JSON representation of the changes made"
help_text="JSON representation of the changes or new object data"
),
),
("reason", models.TextField(help_text="Why this edit is needed")),
(
"reason",
models.TextField(help_text="Why this edit/addition is needed"),
),
(
"source",
models.TextField(
blank=True,
help_text="Source of information for this edit (if applicable)",
blank=True, help_text="Source of information (if applicable)"
),
),
(
"status",
models.CharField(
choices=[
("PENDING", "Pending"),
("NEW", "New"),
("APPROVED", "Approved"),
("REJECTED", "Rejected"),
("AUTO_APPROVED", "Auto Approved"),
("ESCALATED", "Escalated"),
],
default="PENDING",
default="NEW",
max_length=20,
),
),
("submitted_at", models.DateTimeField(auto_now_add=True)),
("reviewed_at", models.DateTimeField(blank=True, null=True)),
("created_at", models.DateTimeField(auto_now_add=True)),
("handled_at", models.DateTimeField(blank=True, null=True)),
(
"review_notes",
"notes",
models.TextField(
blank=True,
help_text="Notes from the moderator about this submission",
@@ -72,12 +82,12 @@ class Migration(migrations.Migration):
),
),
(
"reviewed_by",
"handled_by",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="reviewed_submissions",
related_name="handled_submissions",
to=settings.AUTH_USER_MODEL,
),
),
@@ -91,7 +101,7 @@ class Migration(migrations.Migration):
),
],
options={
"ordering": ["-submitted_at"],
"ordering": ["-created_at"],
"indexes": [
models.Index(
fields=["content_type", "object_id"],
@@ -123,19 +133,19 @@ class Migration(migrations.Migration):
"status",
models.CharField(
choices=[
("PENDING", "Pending"),
("NEW", "New"),
("APPROVED", "Approved"),
("REJECTED", "Rejected"),
("AUTO_APPROVED", "Auto Approved"),
],
default="PENDING",
default="NEW",
max_length=20,
),
),
("submitted_at", models.DateTimeField(auto_now_add=True)),
("reviewed_at", models.DateTimeField(blank=True, null=True)),
("created_at", models.DateTimeField(auto_now_add=True)),
("handled_at", models.DateTimeField(blank=True, null=True)),
(
"review_notes",
"notes",
models.TextField(
blank=True,
help_text="Notes from the moderator about this photo submission",
@@ -149,12 +159,12 @@ class Migration(migrations.Migration):
),
),
(
"reviewed_by",
"handled_by",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="reviewed_photos",
related_name="handled_photos",
to=settings.AUTH_USER_MODEL,
),
),
@@ -168,7 +178,7 @@ class Migration(migrations.Migration):
),
],
options={
"ordering": ["-submitted_at"],
"ordering": ["-created_at"],
"indexes": [
models.Index(
fields=["content_type", "object_id"],

View File

@@ -0,0 +1,41 @@
# Generated by Django 5.1.3 on 2024-11-13 19:25
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("moderation", "0001_initial"),
]
operations = [
migrations.AlterField(
model_name="editsubmission",
name="status",
field=models.CharField(
choices=[
("PENDING", "Pending"),
("APPROVED", "Approved"),
("REJECTED", "Rejected"),
("ESCALATED", "Escalated"),
],
default="PENDING",
max_length=20,
),
),
migrations.AlterField(
model_name="photosubmission",
name="status",
field=models.CharField(
choices=[
("PENDING", "Pending"),
("APPROVED", "Approved"),
("REJECTED", "Rejected"),
("ESCALATED", "Escalated"),
],
default="PENDING",
max_length=20,
),
),
]

View File

@@ -1,46 +0,0 @@
# Generated by Django 5.1.2 on 2024-10-30 01:07
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("moderation", "0001_initial"),
]
operations = [
migrations.AddField(
model_name="editsubmission",
name="submission_type",
field=models.CharField(
choices=[("EDIT", "Edit Existing"), ("CREATE", "Create New")],
default="EDIT",
max_length=10,
),
),
migrations.AlterField(
model_name="editsubmission",
name="changes",
field=models.JSONField(
help_text="JSON representation of the changes or new object data"
),
),
migrations.AlterField(
model_name="editsubmission",
name="object_id",
field=models.PositiveIntegerField(blank=True, null=True),
),
migrations.AlterField(
model_name="editsubmission",
name="reason",
field=models.TextField(help_text="Why this edit/addition is needed"),
),
migrations.AlterField(
model_name="editsubmission",
name="source",
field=models.TextField(
blank=True, help_text="Source of information (if applicable)"
),
),
]

View File

@@ -1,107 +0,0 @@
# Generated manually
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('moderation', '0002_editsubmission_submission_type_and_more'),
]
operations = [
# EditSubmission changes
migrations.RenameField(
model_name='editsubmission',
old_name='submitted_at',
new_name='created_at',
),
migrations.RenameField(
model_name='editsubmission',
old_name='reviewed_by',
new_name='handled_by',
),
migrations.RenameField(
model_name='editsubmission',
old_name='reviewed_at',
new_name='handled_at',
),
migrations.RenameField(
model_name='editsubmission',
old_name='review_notes',
new_name='notes',
),
migrations.AlterField(
model_name='editsubmission',
name='status',
field=models.CharField(
choices=[
('NEW', 'New'),
('APPROVED', 'Approved'),
('REJECTED', 'Rejected'),
('ESCALATED', 'Escalated'),
],
default='NEW',
max_length=20,
),
),
migrations.AlterField(
model_name='editsubmission',
name='handled_by',
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name='handled_submissions',
to='accounts.user',
),
),
# PhotoSubmission changes
migrations.RenameField(
model_name='photosubmission',
old_name='submitted_at',
new_name='created_at',
),
migrations.RenameField(
model_name='photosubmission',
old_name='reviewed_by',
new_name='handled_by',
),
migrations.RenameField(
model_name='photosubmission',
old_name='reviewed_at',
new_name='handled_at',
),
migrations.RenameField(
model_name='photosubmission',
old_name='review_notes',
new_name='notes',
),
migrations.AlterField(
model_name='photosubmission',
name='status',
field=models.CharField(
choices=[
('NEW', 'New'),
('APPROVED', 'Approved'),
('REJECTED', 'Rejected'),
('AUTO_APPROVED', 'Auto Approved'),
],
default='NEW',
max_length=20,
),
),
migrations.AlterField(
model_name='photosubmission',
name='handled_by',
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name='handled_photos',
to='accounts.user',
),
),
]

View File

@@ -0,0 +1,32 @@
from django.db import migrations
def update_statuses(apps, schema_editor):
EditSubmission = apps.get_model('moderation', 'EditSubmission')
PhotoSubmission = apps.get_model('moderation', 'PhotoSubmission')
# Update EditSubmissions
EditSubmission.objects.filter(status='NEW').update(status='PENDING')
# Update PhotoSubmissions
PhotoSubmission.objects.filter(status='NEW').update(status='PENDING')
PhotoSubmission.objects.filter(status='AUTO_APPROVED').update(status='APPROVED')
def reverse_statuses(apps, schema_editor):
EditSubmission = apps.get_model('moderation', 'EditSubmission')
PhotoSubmission = apps.get_model('moderation', 'PhotoSubmission')
# Reverse EditSubmissions
EditSubmission.objects.filter(status='PENDING').update(status='NEW')
# Reverse PhotoSubmissions
PhotoSubmission.objects.filter(status='PENDING').update(status='NEW')
class Migration(migrations.Migration):
dependencies = [
('moderation', '0002_alter_editsubmission_status_and_more'),
]
operations = [
migrations.RunPython(update_statuses, reverse_statuses),
]

View File

@@ -0,0 +1,22 @@
# Generated by Django 5.1.3 on 2024-11-13 20:00
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("moderation", "0003_update_existing_statuses"),
]
operations = [
migrations.AddField(
model_name="editsubmission",
name="moderator_changes",
field=models.JSONField(
blank=True,
help_text="Moderator's edited version of the changes before approval",
null=True,
),
),
]

View File

@@ -1,21 +0,0 @@
# Generated by Django 5.1.2 on 2024-11-02 23:30
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("moderation", "0003_rename_fields_and_update_status"),
]
operations = [
migrations.AlterModelOptions(
name="editsubmission",
options={"ordering": ["-created_at"]},
),
migrations.AlterModelOptions(
name="photosubmission",
options={"ordering": ["-created_at"]},
),
]

View File

@@ -8,59 +8,61 @@ from django.apps import apps
from django.core.exceptions import ObjectDoesNotExist, FieldDoesNotExist
from django.contrib.auth.base_user import AbstractBaseUser
from django.contrib.auth.models import AnonymousUser
from django.utils.text import slugify
UserType = Union[AbstractBaseUser, AnonymousUser]
class EditSubmission(models.Model):
STATUS_CHOICES = [
('NEW', 'New'),
('APPROVED', 'Approved'),
('REJECTED', 'Rejected'),
('ESCALATED', 'Escalated'),
("PENDING", "Pending"),
("APPROVED", "Approved"),
("REJECTED", "Rejected"),
("ESCALATED", "Escalated"),
]
SUBMISSION_TYPE_CHOICES = [
('EDIT', 'Edit Existing'),
('CREATE', 'Create New'),
("EDIT", "Edit Existing"),
("CREATE", "Create New"),
]
# Who submitted the edit
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='edit_submissions'
related_name="edit_submissions",
)
# What is being edited (Park or Ride)
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
object_id = models.PositiveIntegerField(null=True, blank=True) # Null for new objects
content_object = GenericForeignKey('content_type', 'object_id')
object_id = models.PositiveIntegerField(
null=True, blank=True
) # Null for new objects
content_object = GenericForeignKey("content_type", "object_id")
# Type of submission
submission_type = models.CharField(
max_length=10,
choices=SUBMISSION_TYPE_CHOICES,
default='EDIT'
max_length=10, choices=SUBMISSION_TYPE_CHOICES, default="EDIT"
)
# The actual changes/data
changes = models.JSONField(
help_text='JSON representation of the changes or new object data'
help_text="JSON representation of the changes or new object data"
)
# Moderator's edited version of changes before approval
moderator_changes = models.JSONField(
null=True,
blank=True,
help_text="Moderator's edited version of the changes before approval"
)
# Metadata
reason = models.TextField(
help_text='Why this edit/addition is needed'
)
reason = models.TextField(help_text="Why this edit/addition is needed")
source = models.TextField(
blank=True,
help_text='Source of information (if applicable)'
)
status = models.CharField(
max_length=20,
choices=STATUS_CHOICES,
default='NEW'
blank=True, help_text="Source of information (if applicable)"
)
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default="PENDING")
created_at = models.DateTimeField(auto_now_add=True)
# Review details
@@ -69,125 +71,159 @@ class EditSubmission(models.Model):
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='handled_submissions'
related_name="handled_submissions",
)
handled_at = models.DateTimeField(null=True, blank=True)
notes = models.TextField(
blank=True,
help_text='Notes from the moderator about this submission'
blank=True, help_text="Notes from the moderator about this submission"
)
class Meta:
ordering = ['-created_at']
ordering = ["-created_at"]
indexes = [
models.Index(fields=['content_type', 'object_id']),
models.Index(fields=['status']),
models.Index(fields=["content_type", "object_id"]),
models.Index(fields=["status"]),
]
def __str__(self) -> str:
action = "creation" if self.submission_type == 'CREATE' else "edit"
model_class = self.content_type.model_class()
target = self.content_object or (model_class.__name__ if model_class else 'Unknown')
action = "creation" if self.submission_type == "CREATE" else "edit"
if model_class := self.content_type.model_class():
target = self.content_object or model_class.__name__
else:
target = "Unknown"
return f"{action} by {self.user.username} on {target}"
def _resolve_foreign_keys(self, data: Dict[str, Any]) -> Dict[str, Any]:
"""Convert foreign key IDs to model instances"""
model_class = self.content_type.model_class()
if not model_class:
if not (model_class := self.content_type.model_class()):
raise ValueError("Could not resolve model class")
resolved_data = data.copy()
for field_name, value in data.items():
try:
field = model_class._meta.get_field(field_name)
if isinstance(field, models.ForeignKey) and value is not None:
related_model = field.related_model
if related_model:
if (field := model_class._meta.get_field(field_name)) and isinstance(field, models.ForeignKey) and value is not None:
if related_model := field.related_model:
resolved_data[field_name] = related_model.objects.get(id=value)
except (FieldDoesNotExist, ObjectDoesNotExist):
continue
return resolved_data
def _prepare_model_data(self, data: Dict[str, Any], model_class: Type[models.Model]) -> Dict[str, Any]:
"""Prepare data for model creation/update by filtering out auto-generated fields"""
prepared_data = data.copy()
# Remove fields that are auto-generated or handled by the model's save method
auto_fields = {'created_at', 'updated_at', 'slug'}
for field in auto_fields:
prepared_data.pop(field, None)
# Set default values for required fields if not provided
for field in model_class._meta.fields:
if not field.auto_created and not field.blank and not field.null:
if field.name not in prepared_data and field.has_default():
prepared_data[field.name] = field.get_default()
return prepared_data
def _check_duplicate_name(self, model_class: Type[models.Model], name: str) -> Optional[models.Model]:
"""Check if an object with the same name already exists"""
try:
return model_class.objects.filter(name=name).first()
except:
return None
def approve(self, user: UserType) -> Optional[models.Model]:
"""Approve the submission and apply the changes"""
self.status = 'APPROVED'
self.handled_by = user # type: ignore
self.handled_at = timezone.now()
model_class = self.content_type.model_class()
if not model_class:
if not (model_class := self.content_type.model_class()):
raise ValueError("Could not resolve model class")
try:
resolved_data = self._resolve_foreign_keys(self.changes)
# Use moderator_changes if available, otherwise use original changes
changes_to_apply = self.moderator_changes if self.moderator_changes is not None else self.changes
if self.submission_type == 'CREATE':
resolved_data = self._resolve_foreign_keys(changes_to_apply)
prepared_data = self._prepare_model_data(resolved_data, model_class)
# For CREATE submissions, check for duplicates by name
if self.submission_type == "CREATE" and "name" in prepared_data:
if existing_obj := self._check_duplicate_name(model_class, prepared_data["name"]):
self.status = "REJECTED"
self.handled_by = user # type: ignore
self.handled_at = timezone.now()
self.notes = f"A {model_class.__name__} with the name '{prepared_data['name']}' already exists (ID: {existing_obj.id})"
self.save()
raise ValueError(self.notes)
self.status = "APPROVED"
self.handled_by = user # type: ignore
self.handled_at = timezone.now()
if self.submission_type == "CREATE":
# Create new object
obj = model_class(**resolved_data)
obj = model_class(**prepared_data)
obj.save()
# Update object_id after creation
self.object_id = getattr(obj, 'id', None)
self.object_id = getattr(obj, "id", None)
else:
# Apply changes to existing object
obj = self.content_object
if not obj:
if not (obj := self.content_object):
raise ValueError("Content object not found")
for field, value in resolved_data.items():
for field, value in prepared_data.items():
setattr(obj, field, value)
obj.save()
self.save()
return obj
except Exception as e:
if self.status != "REJECTED": # Don't override if already rejected due to duplicate
self.status = "PENDING" # Reset status if approval failed
self.save()
raise ValueError(f"Error approving submission: {str(e)}") from e
def reject(self, user: UserType) -> None:
"""Reject the submission"""
self.status = 'REJECTED'
self.status = "REJECTED"
self.handled_by = user # type: ignore
self.handled_at = timezone.now()
self.save()
def escalate(self, user: UserType) -> None:
"""Escalate the submission to admin"""
self.status = 'ESCALATED'
self.status = "ESCALATED"
self.handled_by = user # type: ignore
self.handled_at = timezone.now()
self.save()
class PhotoSubmission(models.Model):
STATUS_CHOICES = [
('NEW', 'New'),
('APPROVED', 'Approved'),
('REJECTED', 'Rejected'),
('AUTO_APPROVED', 'Auto Approved'),
("PENDING", "Pending"),
("APPROVED", "Approved"),
("REJECTED", "Rejected"),
("ESCALATED", "Escalated"),
]
# Who submitted the photo
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='photo_submissions'
related_name="photo_submissions",
)
# What the photo is for (Park or Ride)
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
object_id = models.PositiveIntegerField()
content_object = GenericForeignKey('content_type', 'object_id')
content_object = GenericForeignKey("content_type", "object_id")
# The photo itself
photo = models.ImageField(upload_to='submissions/photos/')
photo = models.ImageField(upload_to="submissions/photos/")
caption = models.CharField(max_length=255, blank=True)
date_taken = models.DateField(null=True, blank=True)
# Metadata
status = models.CharField(
max_length=20,
choices=STATUS_CHOICES,
default='NEW'
)
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default="PENDING")
created_at = models.DateTimeField(auto_now_add=True)
# Review details
@@ -196,29 +232,28 @@ class PhotoSubmission(models.Model):
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='handled_photos'
related_name="handled_photos",
)
handled_at = models.DateTimeField(null=True, blank=True)
notes = models.TextField(
blank=True,
help_text='Notes from the moderator about this photo submission'
blank=True, help_text="Notes from the moderator about this photo submission"
)
class Meta:
ordering = ['-created_at']
ordering = ["-created_at"]
indexes = [
models.Index(fields=['content_type', 'object_id']),
models.Index(fields=['status']),
models.Index(fields=["content_type", "object_id"]),
models.Index(fields=["status"]),
]
def __str__(self) -> str:
return f"Photo submission by {self.user.username} for {self.content_object}"
def approve(self, moderator: UserType, notes: str = '') -> None:
def approve(self, moderator: UserType, notes: str = "") -> None:
"""Approve the photo submission"""
from media.models import Photo
self.status = 'APPROVED'
self.status = "APPROVED"
self.handled_by = moderator # type: ignore
self.handled_at = timezone.now()
self.notes = notes
@@ -230,35 +265,23 @@ class PhotoSubmission(models.Model):
object_id=self.object_id,
image=self.photo,
caption=self.caption,
is_approved=True
is_approved=True,
)
self.save()
def reject(self, moderator: UserType, notes: str) -> None:
"""Reject the photo submission"""
self.status = 'REJECTED'
self.status = "REJECTED"
self.handled_by = moderator # type: ignore
self.handled_at = timezone.now()
self.notes = notes
self.save()
def auto_approve(self) -> None:
"""Auto-approve the photo submission (for moderators/admins)"""
from media.models import Photo
self.status = 'AUTO_APPROVED'
self.handled_by = self.user
def escalate(self, moderator: UserType, notes: str = "") -> None:
"""Escalate the photo submission to admin"""
self.status = "ESCALATED"
self.handled_by = moderator # type: ignore
self.handled_at = timezone.now()
# Create the approved photo
Photo.objects.create(
uploaded_by=self.user,
content_type=self.content_type,
object_id=self.object_id,
image=self.photo,
caption=self.caption,
is_approved=True
)
self.notes = notes
self.save()

View File

@@ -0,0 +1,62 @@
from django import template
from django.utils.safestring import mark_safe
from django.contrib.contenttypes.models import ContentType
from django.db.models import Model
from typing import Optional, Dict, Any, List, Union
register = template.Library()
@register.filter
def get_object_name(value: Optional[int], model_path: str) -> Optional[str]:
"""Get object name from ID and model path."""
if not value or not model_path or '.' not in model_path:
return None
app_label, model = model_path.split('.')
try:
content_type = ContentType.objects.get(app_label=app_label.lower(), model=model.lower())
model_class = content_type.model_class()
if not model_class:
return None
obj = model_class.objects.filter(id=value).first()
return str(obj) if obj else None
except Exception:
return None
@register.filter
def get_category_display(value: Optional[str]) -> Optional[str]:
"""Get display value for ride category."""
if not value:
return None
categories = {
'RC': 'Roller Coaster',
'DR': 'Dark Ride',
'FR': 'Flat Ride',
'WR': 'Water Ride',
'TR': 'Transport',
'OT': 'Other'
}
return categories.get(value)
@register.filter
def get_park_area_name(value: Optional[int], park_id: Optional[int]) -> Optional[str]:
"""Get park area name from ID and park ID."""
if not value or not park_id:
return None
try:
from parks.models import ParkArea
area = ParkArea.objects.filter(id=value, park_id=park_id).first()
return str(area) if area else None
except Exception:
return None
@register.filter
def get_item(dictionary: Optional[Dict[str, Any]], key: Optional[Union[str, int]]) -> List[Any]:
"""Get item from dictionary by key."""
if not dictionary or not isinstance(dictionary, dict) or not key:
return []
return dictionary.get(str(key), [])

View File

@@ -1,11 +1,34 @@
from django.urls import path
from django.shortcuts import redirect
from django.urls import reverse_lazy
from . import views
app_name = 'moderation'
def redirect_to_dashboard(request):
return redirect(reverse_lazy('moderation:dashboard'))
urlpatterns = [
path('submissions/', views.EditSubmissionListView.as_view(), name='edit_submissions'),
# Root URL redirects to dashboard
path('', redirect_to_dashboard),
# Dashboard and Submissions
path('dashboard/', views.DashboardView.as_view(), name='dashboard'),
path('submissions/', views.submission_list, name='submission_list'),
# Search endpoints
path('search/parks/', views.search_parks, name='search_parks'),
path('search/manufacturers/', views.search_manufacturers, name='search_manufacturers'),
path('search/designers/', views.search_designers, name='search_designers'),
path('search/ride-models/', views.search_ride_models, name='search_ride_models'),
# Submission Actions
path('submissions/<int:submission_id>/edit/', views.edit_submission, name='edit_submission'),
path('submissions/<int:submission_id>/approve/', views.approve_submission, name='approve_submission'),
path('submissions/<int:submission_id>/reject/', views.reject_submission, name='reject_submission'),
path('submissions/<int:submission_id>/escalate/', views.escalate_submission, name='escalate_submission'),
# Photo Submissions
path('photos/<int:submission_id>/approve/', views.approve_photo, name='approve_photo'),
path('photos/<int:submission_id>/reject/', views.reject_photo, name='reject_photo'),
]

View File

@@ -1,90 +1,386 @@
from django.views.generic import ListView
from django.views.generic import ListView, TemplateView
from django.shortcuts import get_object_or_404, render
from django.http import HttpResponse, JsonResponse, HttpRequest
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
from django.shortcuts import get_object_or_404
from django.http import HttpResponse
from django.contrib import messages
from django.db.models import Q
from .models import EditSubmission
from django.contrib.auth.decorators import login_required
from django.template.loader import render_to_string
from django.db.models import Q, QuerySet
from django.core.exceptions import PermissionDenied
from typing import Optional, Any, Dict, List, Tuple, Union, cast
from django.db import models
from django.core.serializers.json import DjangoJSONEncoder
import json
from accounts.models import User
from .models import EditSubmission, PhotoSubmission
from parks.models import Park, ParkArea
from designers.models import Designer
from companies.models import Manufacturer
from rides.models import RideModel
from location.models import Location
MODERATOR_ROLES = ['MODERATOR', 'ADMIN', 'SUPERUSER']
class ModeratorRequiredMixin(UserPassesTestMixin):
def test_func(self):
return self.request.user.role in ['MODERATOR', 'ADMIN', 'SUPERUSER']
request: HttpRequest
class EditSubmissionListView(LoginRequiredMixin, ModeratorRequiredMixin, ListView):
model = EditSubmission
template_name = 'moderation/edit_submissions.html'
context_object_name = 'submissions'
def test_func(self) -> bool:
"""Check if user has moderator permissions."""
user = cast(User, self.request.user)
return user.is_authenticated and (user.role in MODERATOR_ROLES or user.is_superuser)
def get_queryset(self):
tab = self.request.GET.get('tab', 'new')
queryset = EditSubmission.objects.select_related('user', 'content_type')
def handle_no_permission(self) -> HttpResponse:
if not self.request.user.is_authenticated:
return super().handle_no_permission()
raise PermissionDenied("You do not have moderator permissions.")
# Include edits by privileged users (mods, admins, superusers) in appropriate tabs
privileged_roles = ['MODERATOR', 'ADMIN', 'SUPERUSER']
def get_filtered_queryset(request: HttpRequest, status: str, submission_type: str) -> QuerySet:
"""Get filtered queryset based on request parameters."""
if submission_type == 'photo':
return PhotoSubmission.objects.filter(status=status).order_by('-created_at')
if tab == 'new':
# Show pending submissions, oldest first
queryset = queryset.filter(status='NEW').order_by('created_at')
elif tab == 'approved':
# Show approved submissions and auto-approved edits by privileged users
queryset = queryset.filter(
Q(status='APPROVED') |
Q(user__role__in=privileged_roles, status='NEW') # Include privileged users' edits
).order_by('-created_at')
elif tab == 'rejected':
# Show rejected submissions, newest first
queryset = queryset.filter(status='REJECTED').order_by('-created_at')
elif tab == 'escalated' and self.request.user.role in ['ADMIN', 'SUPERUSER']:
# Show escalated submissions, newest first
queryset = queryset.filter(status='ESCALATED').order_by('-created_at')
else:
# Default to new submissions if invalid tab
queryset = queryset.filter(status='NEW').order_by('created_at')
queryset = EditSubmission.objects.filter(status=status).order_by('-created_at')
if type_filter := request.GET.get('type'):
queryset = queryset.filter(submission_type=type_filter)
if content_type := request.GET.get('content_type'):
queryset = queryset.filter(content_type__model=content_type)
return queryset
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['active_tab'] = self.request.GET.get('tab', 'new')
context['new_count'] = EditSubmission.objects.filter(status='NEW').count()
if self.request.user.role in ['ADMIN', 'SUPERUSER']:
context['escalated_count'] = EditSubmission.objects.filter(status='ESCALATED').count()
return context
def get_context_data(request: HttpRequest, queryset: QuerySet) -> Dict[str, Any]:
"""Get common context data for views."""
park_areas_by_park: Dict[int, List[Tuple[int, str]]] = {}
def get_template_names(self):
if self.request.htmx:
return ['moderation/partials/submission_list.html']
if isinstance(queryset.first(), EditSubmission):
for submission in queryset:
if (submission.content_type.model == 'park' and
isinstance(submission.changes, dict) and
'park' in submission.changes):
park_id = submission.changes['park']
if park_id not in park_areas_by_park:
areas = ParkArea.objects.filter(park_id=park_id)
park_areas_by_park[park_id] = [(area.pk, str(area)) for area in areas]
return {
'submissions': queryset,
'user': request.user,
'parks': [(park.pk, str(park)) for park in Park.objects.all()],
'designers': [(designer.pk, str(designer)) for designer in Designer.objects.all()],
'manufacturers': [(manufacturer.pk, str(manufacturer)) for manufacturer in Manufacturer.objects.all()],
'ride_models': [(model.pk, str(model)) for model in RideModel.objects.all()],
'owners': [(user.pk, str(user)) for user in User.objects.filter(role__in=['OWNER', 'ADMIN', 'SUPERUSER'])],
'park_areas_by_park': park_areas_by_park
}
@login_required
def search_parks(request: HttpRequest) -> HttpResponse:
"""HTMX endpoint for searching parks in moderation dashboard"""
user = cast(User, request.user)
if not (user.role in MODERATOR_ROLES or user.is_superuser):
return HttpResponse(status=403)
query = request.GET.get('q', '').strip()
submission_id = request.GET.get('submission_id')
parks = Park.objects.all().order_by('name')
if query:
parks = parks.filter(name__icontains=query)
parks = parks[:10]
return render(request, 'moderation/partials/park_search_results.html', {
'parks': parks,
'search_term': query,
'submission_id': submission_id
})
@login_required
def search_manufacturers(request: HttpRequest) -> HttpResponse:
"""HTMX endpoint for searching manufacturers in moderation dashboard"""
user = cast(User, request.user)
if not (user.role in MODERATOR_ROLES or user.is_superuser):
return HttpResponse(status=403)
query = request.GET.get('q', '').strip()
submission_id = request.GET.get('submission_id')
manufacturers = Manufacturer.objects.all().order_by('name')
if query:
manufacturers = manufacturers.filter(name__icontains=query)
manufacturers = manufacturers[:10]
return render(request, 'moderation/partials/manufacturer_search_results.html', {
'manufacturers': manufacturers,
'search_term': query,
'submission_id': submission_id
})
@login_required
def search_designers(request: HttpRequest) -> HttpResponse:
"""HTMX endpoint for searching designers in moderation dashboard"""
user = cast(User, request.user)
if not (user.role in MODERATOR_ROLES or user.is_superuser):
return HttpResponse(status=403)
query = request.GET.get('q', '').strip()
submission_id = request.GET.get('submission_id')
designers = Designer.objects.all().order_by('name')
if query:
designers = designers.filter(name__icontains=query)
designers = designers[:10]
return render(request, 'moderation/partials/designer_search_results.html', {
'designers': designers,
'search_term': query,
'submission_id': submission_id
})
@login_required
def search_ride_models(request: HttpRequest) -> HttpResponse:
"""HTMX endpoint for searching ride models in moderation dashboard"""
user = cast(User, request.user)
if not (user.role in MODERATOR_ROLES or user.is_superuser):
return HttpResponse(status=403)
query = request.GET.get('q', '').strip()
submission_id = request.GET.get('submission_id')
manufacturer_id = request.GET.get('manufacturer')
queryset = RideModel.objects.all()
if manufacturer_id:
queryset = queryset.filter(manufacturer_id=manufacturer_id)
if query:
queryset = queryset.filter(name__icontains=query)
queryset = queryset.order_by('name')[:10]
return render(request, 'moderation/partials/ride_model_search_results.html', {
'ride_models': queryset,
'search_term': query,
'submission_id': submission_id
})
class DashboardView(LoginRequiredMixin, ModeratorRequiredMixin, ListView):
template_name = 'moderation/dashboard.html'
context_object_name = 'submissions'
paginate_by = 10
def get_template_names(self) -> List[str]:
if self.request.headers.get('HX-Request'):
return ['moderation/partials/dashboard_content.html']
return [self.template_name]
def approve_submission(request, submission_id):
def get_queryset(self) -> QuerySet:
status = self.request.GET.get('status', 'PENDING')
submission_type = self.request.GET.get('submission_type', '')
return get_filtered_queryset(self.request, status, submission_type)
@login_required
def submission_list(request: HttpRequest) -> HttpResponse:
"""View for submission list with filters"""
user = cast(User, request.user)
if not (user.role in MODERATOR_ROLES or user.is_superuser):
return HttpResponse(status=403)
status = request.GET.get('status', 'PENDING')
submission_type = request.GET.get('submission_type', '')
queryset = get_filtered_queryset(request, status, submission_type)
# Process location data for park submissions
for submission in queryset:
if (submission.content_type.model == 'park' and
isinstance(submission.changes, dict)):
# Extract location fields into a location object
location_fields = ['latitude', 'longitude', 'street_address', 'city', 'state', 'postal_code', 'country']
location_data = {field: submission.changes.get(field) for field in location_fields}
# Add location data back as a single object
submission.changes['location'] = location_data
context = get_context_data(request, queryset)
template_name = ('moderation/partials/dashboard_content.html'
if request.headers.get('HX-Request')
else 'moderation/dashboard.html')
return render(request, template_name, context)
@login_required
def edit_submission(request: HttpRequest, submission_id: int) -> HttpResponse:
"""HTMX endpoint for editing a submission"""
user = cast(User, request.user)
if not (user.role in MODERATOR_ROLES or user.is_superuser):
return HttpResponse(status=403)
submission = get_object_or_404(EditSubmission, id=submission_id)
if request.user.role in ['MODERATOR', 'ADMIN', 'SUPERUSER']:
submission.approve(request.user)
messages.success(request, 'Submission approved successfully')
if request.method != 'POST':
return HttpResponse("Invalid request method", status=405)
# Return updated submission list for current tab
view = EditSubmissionListView.as_view()
return view(request)
notes = request.POST.get('notes')
if not notes:
return HttpResponse("Notes are required when editing a submission", status=400)
def reject_submission(request, submission_id):
try:
edited_changes = dict(submission.changes) if submission.changes else {}
# Update stats if present
if 'stats' in edited_changes:
edited_stats = {}
for key in edited_changes['stats']:
if new_value := request.POST.get(f'stats.{key}'):
edited_stats[key] = new_value
edited_changes['stats'] = edited_stats
# Update location fields if present
if submission.content_type.model == 'park':
location_fields = ['latitude', 'longitude', 'street_address', 'city', 'state', 'postal_code', 'country']
location_data = {}
for field in location_fields:
if new_value := request.POST.get(field):
if field in ['latitude', 'longitude']:
try:
location_data[field] = float(new_value)
except ValueError:
return HttpResponse(f"Invalid value for {field}", status=400)
else:
location_data[field] = new_value
if location_data:
edited_changes.update(location_data)
# Update other fields
for field in edited_changes:
if field == 'stats' or field in ['latitude', 'longitude', 'street_address', 'city', 'state', 'postal_code', 'country']:
continue
if new_value := request.POST.get(field):
if field in ['size_acres']:
try:
edited_changes[field] = float(new_value)
except ValueError:
return HttpResponse(f"Invalid value for {field}", status=400)
else:
edited_changes[field] = new_value
# Convert to JSON-serializable format
json_changes = json.loads(json.dumps(edited_changes, cls=DjangoJSONEncoder))
submission.moderator_changes = json_changes
submission.notes = notes
submission.save()
# Process location data for display
if submission.content_type.model == 'park':
location_fields = ['latitude', 'longitude', 'street_address', 'city', 'state', 'postal_code', 'country']
location_data = {field: json_changes.get(field) for field in location_fields}
# Add location data back as a single object
json_changes['location'] = location_data
submission.changes = json_changes
context = get_context_data(request, EditSubmission.objects.filter(id=submission_id))
return render(request, 'moderation/partials/submission_list.html', context)
except Exception as e:
return HttpResponse(str(e), status=400)
@login_required
def approve_submission(request: HttpRequest, submission_id: int) -> HttpResponse:
"""HTMX endpoint for approving a submission"""
user = cast(User, request.user)
submission = get_object_or_404(EditSubmission, id=submission_id)
if request.user.role in ['MODERATOR', 'ADMIN', 'SUPERUSER']:
submission.reject(request.user)
messages.success(request, 'Submission rejected successfully')
if not ((submission.status != 'ESCALATED' and user.role in MODERATOR_ROLES) or
user.role in ['ADMIN', 'SUPERUSER'] or user.is_superuser):
return HttpResponse("Insufficient permissions", status=403)
# Return updated submission list for current tab
view = EditSubmissionListView.as_view()
return view(request)
try:
submission.approve(user)
_update_submission_notes(submission, request.POST.get('notes'))
def escalate_submission(request, submission_id):
status = request.GET.get('status', 'PENDING')
submission_type = request.GET.get('submission_type', '')
queryset = get_filtered_queryset(request, status, submission_type)
return render(request, 'moderation/partials/dashboard_content.html', {
'submissions': queryset,
'user': request.user,
})
except ValueError as e:
return HttpResponse(str(e), status=400)
@login_required
def reject_submission(request: HttpRequest, submission_id: int) -> HttpResponse:
"""HTMX endpoint for rejecting a submission"""
user = cast(User, request.user)
submission = get_object_or_404(EditSubmission, id=submission_id)
if request.user.role == 'MODERATOR':
submission.escalate(request.user)
messages.success(request, 'Submission escalated to admin')
if not ((submission.status != 'ESCALATED' and user.role in MODERATOR_ROLES) or
user.role in ['ADMIN', 'SUPERUSER'] or user.is_superuser):
return HttpResponse("Insufficient permissions", status=403)
# Return updated submission list for current tab
view = EditSubmissionListView.as_view()
return view(request)
submission.reject(user)
_update_submission_notes(submission, request.POST.get('notes'))
status = request.GET.get('status', 'PENDING')
submission_type = request.GET.get('submission_type', '')
queryset = get_filtered_queryset(request, status, submission_type)
context = get_context_data(request, queryset)
return render(request, 'moderation/partials/submission_list.html', context)
@login_required
def escalate_submission(request: HttpRequest, submission_id: int) -> HttpResponse:
"""HTMX endpoint for escalating a submission"""
user = cast(User, request.user)
if not (user.role in MODERATOR_ROLES or user.is_superuser):
return HttpResponse(status=403)
submission = get_object_or_404(EditSubmission, id=submission_id)
if submission.status == "ESCALATED":
return HttpResponse("Submission is already escalated", status=400)
submission.escalate(user)
_update_submission_notes(submission, request.POST.get("notes"))
status = request.GET.get("status", "PENDING")
submission_type = request.GET.get("submission_type", "")
queryset = get_filtered_queryset(request, status, submission_type)
return render(request, "moderation/partials/dashboard_content.html", {
"submissions": queryset,
"user": request.user,
})
@login_required
def approve_photo(request: HttpRequest, submission_id: int) -> HttpResponse:
"""HTMX endpoint for approving a photo submission"""
user = cast(User, request.user)
if not (user.role in MODERATOR_ROLES or user.is_superuser):
return HttpResponse(status=403)
submission = get_object_or_404(PhotoSubmission, id=submission_id)
try:
submission.approve(user, request.POST.get("notes", ""))
return render(request, "moderation/partials/photo_submission.html",
{"submission": submission})
except Exception as e:
return HttpResponse(str(e), status=400)
@login_required
def reject_photo(request: HttpRequest, submission_id: int) -> HttpResponse:
"""HTMX endpoint for rejecting a photo submission"""
user = cast(User, request.user)
if not (user.role in MODERATOR_ROLES or user.is_superuser):
return HttpResponse(status=403)
submission = get_object_or_404(PhotoSubmission, id=submission_id)
submission.reject(user, request.POST.get("notes", ""))
return render(request, "moderation/partials/photo_submission.html",
{"submission": submission})
def _update_submission_notes(submission: EditSubmission, notes: Optional[str]) -> None:
"""Update submission notes if provided."""
if notes:
submission.notes = notes
submission.save()

View File

@@ -0,0 +1,245 @@
from django.core.management.base import BaseCommand
from django.utils import timezone
from companies.models import Company
from parks.models import Park, ParkArea
from location.models import Location
from django.contrib.contenttypes.models import ContentType
class Command(BaseCommand):
help = 'Seeds initial park data with major theme parks worldwide'
def handle(self, *args, **options):
# Create major theme park companies
companies_data = [
{
'name': 'The Walt Disney Company',
'website': 'https://www.disney.com/',
'headquarters': 'Burbank, California',
'description': 'The world\'s largest entertainment company and theme park operator.'
},
{
'name': 'Universal Parks & Resorts',
'website': 'https://www.universalparks.com/',
'headquarters': 'Orlando, Florida',
'description': 'A division of Comcast NBCUniversal, operating major theme parks worldwide.'
},
{
'name': 'Six Flags Entertainment Corporation',
'website': 'https://www.sixflags.com/',
'headquarters': 'Arlington, Texas',
'description': 'The world\'s largest regional theme park company.'
},
{
'name': 'Cedar Fair Entertainment Company',
'website': 'https://www.cedarfair.com/',
'headquarters': 'Sandusky, Ohio',
'description': 'One of North America\'s largest operators of regional amusement parks.'
},
{
'name': 'Herschend Family Entertainment',
'website': 'https://www.hfecorp.com/',
'headquarters': 'Atlanta, Georgia',
'description': 'The largest family-owned themed attractions corporation in the United States.'
},
{
'name': 'SeaWorld Parks & Entertainment',
'website': 'https://www.seaworldentertainment.com/',
'headquarters': 'Orlando, Florida',
'description': 'Theme park and entertainment company focusing on nature-based themes.'
}
]
companies = {}
for company_data in companies_data:
company, created = Company.objects.get_or_create(
name=company_data['name'],
defaults=company_data
)
companies[company.name] = company
self.stdout.write(f'{"Created" if created else "Found"} company: {company.name}')
# Create parks with their locations
parks_data = [
{
'name': 'Magic Kingdom',
'company': 'The Walt Disney Company',
'description': 'The first theme park at Walt Disney World Resort in Florida, opened in 1971.',
'opening_date': '1971-10-01',
'size_acres': 142,
'location': {
'street_address': '1180 Seven Seas Dr',
'city': 'Lake Buena Vista',
'state': 'Florida',
'country': 'United States',
'postal_code': '32830',
'latitude': 28.4177,
'longitude': -81.5812
},
'areas': [
{'name': 'Main Street, U.S.A.', 'description': 'Victorian-era themed entrance corridor'},
{'name': 'Adventureland', 'description': 'Exotic tropical places themed area'},
{'name': 'Frontierland', 'description': 'American Old West themed area'},
{'name': 'Liberty Square', 'description': 'Colonial America themed area'},
{'name': 'Fantasyland', 'description': 'Fairy tale themed area'},
{'name': 'Tomorrowland', 'description': 'Future themed area'}
]
},
{
'name': 'Universal Studios Florida',
'company': 'Universal Parks & Resorts',
'description': 'Movie and television-based theme park in Orlando, Florida.',
'opening_date': '1990-06-07',
'size_acres': 108,
'location': {
'street_address': '6000 Universal Blvd',
'city': 'Orlando',
'state': 'Florida',
'country': 'United States',
'postal_code': '32819',
'latitude': 28.4749,
'longitude': -81.4687
},
'areas': [
{'name': 'Production Central', 'description': 'Main entrance area with movie-themed attractions'},
{'name': 'New York', 'description': 'Themed after New York City streets'},
{'name': 'San Francisco', 'description': 'Themed after San Francisco\'s waterfront'},
{'name': 'The Wizarding World of Harry Potter - Diagon Alley', 'description': 'Themed after the Harry Potter series'},
{'name': 'Springfield', 'description': 'Themed after The Simpsons hometown'}
]
},
{
'name': 'Cedar Point',
'company': 'Cedar Fair Entertainment Company',
'description': 'Known as the "Roller Coaster Capital of the World".',
'opening_date': '1870-06-01',
'size_acres': 364,
'location': {
'street_address': '1 Cedar Point Dr',
'city': 'Sandusky',
'state': 'Ohio',
'country': 'United States',
'postal_code': '44870',
'latitude': 41.4822,
'longitude': -82.6835
},
'areas': [
{'name': 'Frontiertown', 'description': 'Western-themed area with multiple roller coasters'},
{'name': 'Millennium Island', 'description': 'Home to the Millennium Force roller coaster'},
{'name': 'Cedar Point Shores', 'description': 'Waterpark area'},
{'name': 'Top Thrill Dragster', 'description': 'Area surrounding the iconic launched coaster'}
]
},
{
'name': 'Silver Dollar City',
'company': 'Herschend Family Entertainment',
'description': 'An 1880s-themed park featuring over 40 rides and attractions.',
'opening_date': '1960-05-01',
'size_acres': 61,
'location': {
'street_address': '399 Silver Dollar City Parkway',
'city': 'Branson',
'state': 'Missouri',
'country': 'United States',
'postal_code': '65616',
'latitude': 36.668497,
'longitude': -93.339074
},
'areas': [
{'name': 'Grand Exposition', 'description': 'Home to many family rides and attractions'},
{'name': 'Wildfire', 'description': 'Named after the famous B&M coaster'},
{'name': 'Wilson\'s Farm', 'description': 'Farm-themed attractions and dining'},
{'name': 'Riverfront', 'description': 'Water-themed attractions area'},
{'name': 'The Valley', 'description': 'Home to Time Traveler and other major attractions'}
]
},
{
'name': 'Six Flags Magic Mountain',
'company': 'Six Flags Entertainment Corporation',
'description': 'Known for its world-record 19 roller coasters.',
'opening_date': '1971-05-29',
'size_acres': 262,
'location': {
'street_address': '26101 Magic Mountain Pkwy',
'city': 'Valencia',
'state': 'California',
'country': 'United States',
'postal_code': '91355',
'latitude': 34.4253,
'longitude': -118.5971
},
'areas': [
{'name': 'Six Flags Plaza', 'description': 'Main entrance area'},
{'name': 'DC Universe', 'description': 'DC Comics themed area'},
{'name': 'Screampunk District', 'description': 'Steampunk themed area'},
{'name': 'The Underground', 'description': 'Urban themed area'},
{'name': 'Goliath Territory', 'description': 'Area surrounding the Goliath hypercoaster'}
]
},
{
'name': 'SeaWorld Orlando',
'company': 'SeaWorld Parks & Entertainment',
'description': 'Marine zoological park combined with thrill rides and shows.',
'opening_date': '1973-12-15',
'size_acres': 200,
'location': {
'street_address': '7007 Sea World Dr',
'city': 'Orlando',
'state': 'Florida',
'country': 'United States',
'postal_code': '32821',
'latitude': 28.4115,
'longitude': -81.4617
},
'areas': [
{'name': 'Sea Harbor', 'description': 'Main entrance and shopping area'},
{'name': 'Shark Encounter', 'description': 'Shark exhibit and themed area'},
{'name': 'Antarctica: Empire of the Penguin', 'description': 'Penguin-themed area'},
{'name': 'Manta', 'description': 'Area themed around the Manta flying roller coaster'},
{'name': 'Sesame Street Land', 'description': 'Kid-friendly area based on Sesame Street'}
]
}
]
# Create parks and their areas
for park_data in parks_data:
company = companies[park_data['company']]
park, created = Park.objects.get_or_create(
name=park_data['name'],
defaults={
'description': park_data['description'],
'status': 'OPERATING',
'opening_date': park_data['opening_date'],
'size_acres': park_data['size_acres'],
'owner': company
}
)
self.stdout.write(f'{"Created" if created else "Found"} park: {park.name}')
# Create location for park
if created:
loc_data = park_data['location']
park_content_type = ContentType.objects.get_for_model(Park)
Location.objects.create(
content_type=park_content_type,
object_id=park.id,
street_address=loc_data['street_address'],
city=loc_data['city'],
state=loc_data['state'],
country=loc_data['country'],
postal_code=loc_data['postal_code'],
latitude=loc_data['latitude'],
longitude=loc_data['longitude']
)
# Create areas for park
for area_data in park_data['areas']:
area, created = ParkArea.objects.get_or_create(
name=area_data['name'],
park=park,
defaults={
'description': area_data['description']
}
)
self.stdout.write(f'{"Created" if created else "Found"} area: {area.name} in {park.name}')
self.stdout.write(self.style.SUCCESS('Successfully seeded initial park data'))

View File

@@ -0,0 +1,321 @@
from django.core.management.base import BaseCommand
from django.utils import timezone
from companies.models import Manufacturer
from parks.models import Park
from rides.models import Ride, RollerCoasterStats
from decimal import Decimal
class Command(BaseCommand):
help = 'Seeds ride data for parks'
def handle(self, *args, **options):
# Create major ride manufacturers
manufacturers_data = [
{
'name': 'Bolliger & Mabillard',
'website': 'https://www.bolligermabillard.com/',
'headquarters': 'Monthey, Switzerland',
'description': 'Known for their smooth steel roller coasters.'
},
{
'name': 'Rocky Mountain Construction',
'website': 'https://www.rockymtnconstruction.com/',
'headquarters': 'Hayden, Idaho, USA',
'description': 'Specialists in hybrid and steel roller coasters.'
},
{
'name': 'Intamin',
'website': 'https://www.intamin.com/',
'headquarters': 'Schaan, Liechtenstein',
'description': 'Creators of record-breaking roller coasters and rides.'
},
{
'name': 'Vekoma',
'website': 'https://www.vekoma.com/',
'headquarters': 'Vlodrop, Netherlands',
'description': 'Manufacturers of various roller coaster types.'
},
{
'name': 'Mack Rides',
'website': 'https://mack-rides.com/',
'headquarters': 'Waldkirch, Germany',
'description': 'Family-owned manufacturer of roller coasters and attractions.'
},
{
'name': 'Sally Dark Rides',
'website': 'https://sallydarkrides.com/',
'headquarters': 'Jacksonville, Florida, USA',
'description': 'Specialists in dark rides and interactive attractions.'
},
{
'name': 'Zamperla',
'website': 'https://www.zamperla.com/',
'headquarters': 'Vicenza, Italy',
'description': 'Manufacturer of family rides and thrill attractions.'
}
]
manufacturers = {}
for mfg_data in manufacturers_data:
manufacturer, created = Manufacturer.objects.get_or_create(
name=mfg_data['name'],
defaults=mfg_data
)
manufacturers[manufacturer.name] = manufacturer
self.stdout.write(f'{"Created" if created else "Found"} manufacturer: {manufacturer.name}')
# Create rides for each park
rides_data = [
# Silver Dollar City Rides
{
'park_name': 'Silver Dollar City',
'rides': [
{
'name': 'Time Traveler',
'manufacturer': 'Mack Rides',
'description': 'The world\'s fastest, steepest, and tallest spinning roller coaster.',
'category': 'RC',
'opening_date': '2018-03-14',
'stats': {
'height_ft': 100,
'length_ft': 3020,
'speed_mph': 50.3,
'inversions': 3,
'track_material': 'STEEL',
'roller_coaster_type': 'SPINNING',
'launch_type': 'LSM'
}
},
{
'name': 'Wildfire',
'manufacturer': 'Bolliger & Mabillard',
'description': 'A multi-looping roller coaster with a 155-foot drop.',
'category': 'RC',
'opening_date': '2001-04-01',
'stats': {
'height_ft': 155,
'length_ft': 3073,
'speed_mph': 66,
'inversions': 5,
'track_material': 'STEEL',
'roller_coaster_type': 'SITDOWN',
'launch_type': 'CHAIN'
}
},
{
'name': 'Fire In The Hole',
'manufacturer': 'Sally Dark Rides',
'description': 'Indoor coaster featuring special effects and storytelling.',
'category': 'DR',
'opening_date': '1972-01-01'
},
{
'name': 'American Plunge',
'manufacturer': 'Intamin',
'description': 'Log flume ride with a 50-foot splashdown.',
'category': 'WR',
'opening_date': '1981-01-01'
}
]
},
# Magic Kingdom Rides
{
'park_name': 'Magic Kingdom',
'rides': [
{
'name': 'Space Mountain',
'manufacturer': 'Vekoma',
'description': 'An indoor roller coaster through space.',
'category': 'RC',
'opening_date': '1975-01-15',
'stats': {
'height_ft': 180,
'length_ft': 3196,
'speed_mph': 27,
'inversions': 0,
'track_material': 'STEEL',
'roller_coaster_type': 'SITDOWN',
'launch_type': 'CHAIN'
}
},
{
'name': 'Haunted Mansion',
'manufacturer': 'Sally Dark Rides',
'description': 'Classic dark ride through a haunted estate.',
'category': 'DR',
'opening_date': '1971-10-01'
},
{
'name': 'Mad Tea Party',
'manufacturer': 'Zamperla',
'description': 'Spinning teacup ride based on Alice in Wonderland.',
'category': 'FR',
'opening_date': '1971-10-01'
},
{
'name': 'Splash Mountain',
'manufacturer': 'Intamin',
'description': 'Log flume ride with multiple drops and animatronics.',
'category': 'WR',
'opening_date': '1992-10-02'
}
]
},
# Cedar Point Rides
{
'park_name': 'Cedar Point',
'rides': [
{
'name': 'Millennium Force',
'manufacturer': 'Intamin',
'description': 'Former world\'s tallest and fastest complete-circuit roller coaster.',
'category': 'RC',
'opening_date': '2000-05-13',
'stats': {
'height_ft': 310,
'length_ft': 6595,
'speed_mph': 93,
'inversions': 0,
'track_material': 'STEEL',
'roller_coaster_type': 'SITDOWN',
'launch_type': 'CABLE'
}
},
{
'name': 'Cedar Downs Racing Derby',
'manufacturer': 'Zamperla',
'description': 'High-speed carousel with racing horses.',
'category': 'FR',
'opening_date': '1967-01-01'
},
{
'name': 'Snake River Falls',
'manufacturer': 'Intamin',
'description': 'Shoot-the-Chutes water ride with an 82-foot drop.',
'category': 'WR',
'opening_date': '1993-05-01'
}
]
},
# Universal Studios Florida Rides
{
'park_name': 'Universal Studios Florida',
'rides': [
{
'name': 'Harry Potter and the Escape from Gringotts',
'manufacturer': 'Intamin',
'description': 'Indoor steel roller coaster with 3D effects.',
'category': 'RC',
'opening_date': '2014-07-08',
'stats': {
'height_ft': 65,
'length_ft': 2000,
'speed_mph': 50,
'inversions': 0,
'track_material': 'STEEL',
'roller_coaster_type': 'SITDOWN',
'launch_type': 'LSM'
}
},
{
'name': 'The Amazing Adventures of Spider-Man',
'manufacturer': 'Sally Dark Rides',
'description': 'groundbreaking 3D dark ride.',
'category': 'DR',
'opening_date': '1999-05-28'
},
{
'name': 'Jurassic World VelociCoaster',
'manufacturer': 'Intamin',
'description': 'Florida\'s fastest and tallest launch coaster.',
'category': 'RC',
'opening_date': '2021-06-10',
'stats': {
'height_ft': 155,
'length_ft': 4700,
'speed_mph': 70,
'inversions': 4,
'track_material': 'STEEL',
'roller_coaster_type': 'SITDOWN',
'launch_type': 'LSM'
}
}
]
},
# SeaWorld Orlando Rides
{
'park_name': 'SeaWorld Orlando',
'rides': [
{
'name': 'Mako',
'manufacturer': 'Bolliger & Mabillard',
'description': 'Orlando\'s tallest, fastest and longest roller coaster.',
'category': 'RC',
'opening_date': '2016-06-10',
'stats': {
'height_ft': 200,
'length_ft': 4760,
'speed_mph': 73,
'inversions': 0,
'track_material': 'STEEL',
'roller_coaster_type': 'SITDOWN',
'launch_type': 'CHAIN'
}
},
{
'name': 'Journey to Atlantis',
'manufacturer': 'Mack Rides',
'description': 'Water coaster combining dark ride elements with splashes.',
'category': 'WR',
'opening_date': '1998-03-01'
},
{
'name': 'Sky Tower',
'manufacturer': 'Intamin',
'description': 'Rotating observation tower providing views of Orlando.',
'category': 'TR',
'opening_date': '1973-12-15'
}
]
}
]
# Create rides and their stats
for park_data in rides_data:
try:
park = Park.objects.get(name=park_data['park_name'])
for ride_data in park_data['rides']:
manufacturer = manufacturers[ride_data['manufacturer']]
ride, created = Ride.objects.get_or_create(
name=ride_data['name'],
park=park,
defaults={
'description': ride_data['description'],
'category': ride_data['category'],
'manufacturer': manufacturer,
'opening_date': ride_data['opening_date'],
'status': 'OPERATING'
}
)
self.stdout.write(f'{"Created" if created else "Found"} ride: {ride.name}')
if created and ride_data.get('stats'):
stats = ride_data['stats']
RollerCoasterStats.objects.create(
ride=ride,
height_ft=stats['height_ft'],
length_ft=stats['length_ft'],
speed_mph=stats['speed_mph'],
inversions=stats['inversions'],
track_material=stats['track_material'],
roller_coaster_type=stats['roller_coaster_type'],
launch_type=stats['launch_type']
)
self.stdout.write(f'Created stats for: {ride.name}')
except Park.DoesNotExist:
self.stdout.write(self.style.WARNING(f'Park not found: {park_data["park_name"]}'))
self.stdout.write(self.style.SUCCESS('Successfully seeded ride data'))

View File

@@ -1,5 +1,9 @@
from django.db import migrations, models
# Generated by Django 5.1.3 on 2024-11-12 18:07
import django.db.models.deletion
import simple_history.models
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
@@ -7,57 +11,228 @@ class Migration(migrations.Migration):
initial = True
dependencies = [
('companies', '0001_initial'),
("companies", "0001_initial"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Park',
name="HistoricalPark",
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255)),
('slug', models.SlugField(max_length=255, unique=True)),
('description', models.TextField(blank=True)),
('status', models.CharField(choices=[('OPERATING', 'Operating'), ('CLOSED_TEMP', 'Temporarily Closed'), ('CLOSED_PERM', 'Permanently Closed'), ('UNDER_CONSTRUCTION', 'Under Construction'), ('DEMOLISHED', 'Demolished'), ('RELOCATED', 'Relocated')], default='OPERATING', max_length=20)),
('opening_date', models.DateField(blank=True, null=True)),
('closing_date', models.DateField(blank=True, null=True)),
('operating_season', models.CharField(blank=True, max_length=255)),
('size_acres', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True)),
('website', models.URLField(blank=True)),
('average_rating', models.DecimalField(blank=True, decimal_places=2, max_digits=3, null=True)),
('total_rides', models.IntegerField(blank=True, null=True)),
('total_roller_coasters', models.IntegerField(blank=True, null=True)),
('latitude', models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True)),
('longitude', models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True)),
('street_address', models.CharField(blank=True, max_length=255)),
('city', models.CharField(blank=True, max_length=255)),
('state', models.CharField(blank=True, max_length=255)),
('country', models.CharField(blank=True, max_length=255)),
('postal_code', models.CharField(blank=True, max_length=20)),
('created_at', models.DateTimeField(auto_now_add=True, null=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('owner', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='parks', to='companies.company')),
("id", models.BigIntegerField(blank=True, db_index=True)),
("name", models.CharField(max_length=255)),
("slug", models.SlugField(max_length=255)),
("description", models.TextField(blank=True)),
(
"status",
models.CharField(
choices=[
("OPERATING", "Operating"),
("CLOSED_TEMP", "Temporarily Closed"),
("CLOSED_PERM", "Permanently Closed"),
("UNDER_CONSTRUCTION", "Under Construction"),
("DEMOLISHED", "Demolished"),
("RELOCATED", "Relocated"),
],
default="OPERATING",
max_length=20,
),
),
("opening_date", models.DateField(blank=True, null=True)),
("closing_date", models.DateField(blank=True, null=True)),
("operating_season", models.CharField(blank=True, max_length=255)),
(
"size_acres",
models.DecimalField(
blank=True, decimal_places=2, max_digits=10, null=True
),
),
("website", models.URLField(blank=True)),
(
"average_rating",
models.DecimalField(
blank=True, decimal_places=2, max_digits=3, null=True
),
),
("ride_count", models.IntegerField(blank=True, null=True)),
("coaster_count", models.IntegerField(blank=True, null=True)),
(
"created_at",
models.DateTimeField(blank=True, editable=False, null=True),
),
("updated_at", models.DateTimeField(blank=True, editable=False)),
("history_id", models.AutoField(primary_key=True, serialize=False)),
("history_date", models.DateTimeField(db_index=True)),
("history_change_reason", models.CharField(max_length=100, null=True)),
(
"history_type",
models.CharField(
choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")],
max_length=1,
),
),
(
"history_user",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="+",
to=settings.AUTH_USER_MODEL,
),
),
(
"owner",
models.ForeignKey(
blank=True,
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
to="companies.company",
),
),
],
options={
'ordering': ['name'],
"verbose_name": "historical park",
"verbose_name_plural": "historical parks",
"ordering": ("-history_date", "-history_id"),
"get_latest_by": ("history_date", "history_id"),
},
bases=(simple_history.models.HistoricalChanges, models.Model),
),
migrations.CreateModel(
name="Park",
fields=[
("id", models.BigAutoField(primary_key=True, serialize=False)),
("name", models.CharField(max_length=255)),
("slug", models.SlugField(max_length=255, unique=True)),
("description", models.TextField(blank=True)),
(
"status",
models.CharField(
choices=[
("OPERATING", "Operating"),
("CLOSED_TEMP", "Temporarily Closed"),
("CLOSED_PERM", "Permanently Closed"),
("UNDER_CONSTRUCTION", "Under Construction"),
("DEMOLISHED", "Demolished"),
("RELOCATED", "Relocated"),
],
default="OPERATING",
max_length=20,
),
),
("opening_date", models.DateField(blank=True, null=True)),
("closing_date", models.DateField(blank=True, null=True)),
("operating_season", models.CharField(blank=True, max_length=255)),
(
"size_acres",
models.DecimalField(
blank=True, decimal_places=2, max_digits=10, null=True
),
),
("website", models.URLField(blank=True)),
(
"average_rating",
models.DecimalField(
blank=True, decimal_places=2, max_digits=3, null=True
),
),
("ride_count", models.IntegerField(blank=True, null=True)),
("coaster_count", models.IntegerField(blank=True, null=True)),
("created_at", models.DateTimeField(auto_now_add=True, null=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"owner",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="parks",
to="companies.company",
),
),
],
options={
"ordering": ["name"],
},
),
migrations.CreateModel(
name='ParkArea',
name="HistoricalParkArea",
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255)),
('slug', models.SlugField(max_length=255)),
('description', models.TextField(blank=True)),
('opening_date', models.DateField(blank=True, null=True)),
('closing_date', models.DateField(blank=True, null=True)),
('created_at', models.DateTimeField(auto_now_add=True, null=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('park', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='areas', to='parks.park')),
("id", models.BigIntegerField(blank=True, db_index=True)),
("name", models.CharField(max_length=255)),
("slug", models.SlugField(max_length=255)),
("description", models.TextField(blank=True)),
("opening_date", models.DateField(blank=True, null=True)),
("closing_date", models.DateField(blank=True, null=True)),
(
"created_at",
models.DateTimeField(blank=True, editable=False, null=True),
),
("updated_at", models.DateTimeField(blank=True, editable=False)),
("history_id", models.AutoField(primary_key=True, serialize=False)),
("history_date", models.DateTimeField(db_index=True)),
("history_change_reason", models.CharField(max_length=100, null=True)),
(
"history_type",
models.CharField(
choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")],
max_length=1,
),
),
(
"history_user",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="+",
to=settings.AUTH_USER_MODEL,
),
),
(
"park",
models.ForeignKey(
blank=True,
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
to="parks.park",
),
),
],
options={
'ordering': ['name'],
'unique_together': {('park', 'slug')},
"verbose_name": "historical park area",
"verbose_name_plural": "historical park areas",
"ordering": ("-history_date", "-history_id"),
"get_latest_by": ("history_date", "history_id"),
},
bases=(simple_history.models.HistoricalChanges, models.Model),
),
migrations.CreateModel(
name="ParkArea",
fields=[
("id", models.BigAutoField(primary_key=True, serialize=False)),
("name", models.CharField(max_length=255)),
("slug", models.SlugField(max_length=255)),
("description", models.TextField(blank=True)),
("opening_date", models.DateField(blank=True, null=True)),
("closing_date", models.DateField(blank=True, null=True)),
("created_at", models.DateTimeField(auto_now_add=True, null=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"park",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="areas",
to="parks.park",
),
),
],
options={
"ordering": ["name"],
"unique_together": {("park", "slug")},
},
),
]

View File

@@ -1,84 +0,0 @@
# Generated by Django 5.1.2 on 2024-11-03 03:44
import django.db.models.deletion
import simple_history.models
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('companies', '0004_add_total_parks'),
('parks', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='HistoricalPark',
fields=[
('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')),
('name', models.CharField(max_length=255)),
('slug', models.SlugField(max_length=255)),
('description', models.TextField(blank=True)),
('status', models.CharField(choices=[('OPERATING', 'Operating'), ('CLOSED_TEMP', 'Temporarily Closed'), ('CLOSED_PERM', 'Permanently Closed'), ('UNDER_CONSTRUCTION', 'Under Construction'), ('DEMOLISHED', 'Demolished'), ('RELOCATED', 'Relocated')], default='OPERATING', max_length=20)),
('opening_date', models.DateField(blank=True, null=True)),
('closing_date', models.DateField(blank=True, null=True)),
('operating_season', models.CharField(blank=True, max_length=255)),
('size_acres', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True)),
('website', models.URLField(blank=True)),
('average_rating', models.DecimalField(blank=True, decimal_places=2, max_digits=3, null=True)),
('total_rides', models.IntegerField(blank=True, null=True)),
('total_roller_coasters', models.IntegerField(blank=True, null=True)),
('latitude', models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True)),
('longitude', models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True)),
('street_address', models.CharField(blank=True, max_length=255)),
('city', models.CharField(blank=True, max_length=255)),
('state', models.CharField(blank=True, max_length=255)),
('country', models.CharField(blank=True, max_length=255)),
('postal_code', models.CharField(blank=True, max_length=20)),
('created_at', models.DateTimeField(blank=True, editable=False, null=True)),
('updated_at', models.DateTimeField(blank=True, editable=False)),
('history_id', models.AutoField(primary_key=True, serialize=False)),
('history_date', models.DateTimeField(db_index=True)),
('history_change_reason', models.CharField(max_length=100, null=True)),
('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)),
('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
('owner', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='companies.company')),
],
options={
'verbose_name': 'historical park',
'verbose_name_plural': 'historical parks',
'ordering': ('-history_date', '-history_id'),
'get_latest_by': ('history_date', 'history_id'),
},
bases=(simple_history.models.HistoricalChanges, models.Model),
),
migrations.CreateModel(
name='HistoricalParkArea',
fields=[
('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')),
('name', models.CharField(max_length=255)),
('slug', models.SlugField(max_length=255)),
('description', models.TextField(blank=True)),
('opening_date', models.DateField(blank=True, null=True)),
('closing_date', models.DateField(blank=True, null=True)),
('created_at', models.DateTimeField(blank=True, editable=False, null=True)),
('updated_at', models.DateTimeField(blank=True, editable=False)),
('history_id', models.AutoField(primary_key=True, serialize=False)),
('history_date', models.DateTimeField(db_index=True)),
('history_change_reason', models.CharField(max_length=100, null=True)),
('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)),
('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
('park', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='parks.park')),
],
options={
'verbose_name': 'historical park area',
'verbose_name_plural': 'historical park areas',
'ordering': ('-history_date', '-history_id'),
'get_latest_by': ('history_date', 'history_id'),
},
bases=(simple_history.models.HistoricalChanges, models.Model),
),
]

View File

@@ -1,55 +0,0 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('parks', '0002_historicalpark_historicalparkarea'),
]
operations = [
migrations.AlterField(
model_name='park',
name='latitude',
field=models.DecimalField(
blank=True,
decimal_places=6,
max_digits=9, # Changed to 9 to handle -90.000000 to 90.000000
null=True,
help_text='Latitude coordinate (-90 to 90)',
),
),
migrations.AlterField(
model_name='park',
name='longitude',
field=models.DecimalField(
blank=True,
decimal_places=6,
max_digits=10, # Changed to 10 to handle -180.000000 to 180.000000
null=True,
help_text='Longitude coordinate (-180 to 180)',
),
),
migrations.AlterField(
model_name='historicalpark',
name='latitude',
field=models.DecimalField(
blank=True,
decimal_places=6,
max_digits=9, # Changed to 9 to handle -90.000000 to 90.000000
null=True,
help_text='Latitude coordinate (-90 to 90)',
),
),
migrations.AlterField(
model_name='historicalpark',
name='longitude',
field=models.DecimalField(
blank=True,
decimal_places=6,
max_digits=10, # Changed to 10 to handle -180.000000 to 180.000000
null=True,
help_text='Longitude coordinate (-180 to 180)',
),
),
]

View File

@@ -1,101 +0,0 @@
from django.db import migrations, models
from django.core.validators import MinValueValidator, MaxValueValidator
from decimal import Decimal
from django.core.exceptions import ValidationError
def validate_coordinate_digits(value, max_digits):
"""Validate total number of digits in a coordinate value"""
if value is not None:
# Convert to string and remove decimal point and sign
str_val = str(abs(value)).replace('.', '')
# Remove trailing zeros after decimal point
str_val = str_val.rstrip('0')
if len(str_val) > max_digits:
raise ValidationError(
f'Ensure that there are no more than {max_digits} digits in total.'
)
def validate_latitude_digits(value):
"""Validate total number of digits in latitude"""
validate_coordinate_digits(value, 9)
def validate_longitude_digits(value):
"""Validate total number of digits in longitude"""
validate_coordinate_digits(value, 10)
class Migration(migrations.Migration):
dependencies = [
('parks', '0003_update_coordinate_fields'),
]
operations = [
migrations.AlterField(
model_name='park',
name='latitude',
field=models.DecimalField(
blank=True,
decimal_places=6,
help_text='Latitude coordinate (-90 to 90)',
max_digits=9,
null=True,
validators=[
MinValueValidator(Decimal('-90')),
MaxValueValidator(Decimal('90')),
validate_latitude_digits,
],
),
),
migrations.AlterField(
model_name='park',
name='longitude',
field=models.DecimalField(
blank=True,
decimal_places=6,
help_text='Longitude coordinate (-180 to 180)',
max_digits=10,
null=True,
validators=[
MinValueValidator(Decimal('-180')),
MaxValueValidator(Decimal('180')),
validate_longitude_digits,
],
),
),
migrations.AlterField(
model_name='historicalpark',
name='latitude',
field=models.DecimalField(
blank=True,
decimal_places=6,
help_text='Latitude coordinate (-90 to 90)',
max_digits=9,
null=True,
validators=[
MinValueValidator(Decimal('-90')),
MaxValueValidator(Decimal('90')),
validate_latitude_digits,
],
),
),
migrations.AlterField(
model_name='historicalpark',
name='longitude',
field=models.DecimalField(
blank=True,
decimal_places=6,
help_text='Longitude coordinate (-180 to 180)',
max_digits=10,
null=True,
validators=[
MinValueValidator(Decimal('-180')),
MaxValueValidator(Decimal('180')),
validate_longitude_digits,
],
),
),
]

View File

@@ -1,58 +0,0 @@
from django.db import migrations
from decimal import Decimal, ROUND_DOWN
def normalize_coordinate(value, max_digits, decimal_places):
"""Normalize coordinate to have exactly 6 decimal places"""
try:
if value is None:
return None
# Convert to Decimal for precise handling
value = Decimal(str(value))
# Round to exactly 6 decimal places
value = value.quantize(Decimal('0.000001'), rounding=ROUND_DOWN)
return value
except (TypeError, ValueError):
return None
def normalize_existing_coordinates(apps, schema_editor):
Park = apps.get_model('parks', 'Park')
HistoricalPark = apps.get_model('parks', 'HistoricalPark')
# Normalize coordinates in current parks
for park in Park.objects.all():
if park.latitude is not None:
park.latitude = normalize_coordinate(park.latitude, 9, 6)
if park.longitude is not None:
park.longitude = normalize_coordinate(park.longitude, 10, 6)
park.save()
# Normalize coordinates in historical records
for record in HistoricalPark.objects.all():
if record.latitude is not None:
record.latitude = normalize_coordinate(record.latitude, 9, 6)
if record.longitude is not None:
record.longitude = normalize_coordinate(record.longitude, 10, 6)
record.save()
def reverse_normalize_coordinates(apps, schema_editor):
# No need to reverse normalization as it would only reduce precision
pass
class Migration(migrations.Migration):
dependencies = [
('parks', '0004_add_coordinate_validators'),
]
operations = [
migrations.RunPython(
normalize_existing_coordinates,
reverse_normalize_coordinates
),
]

View File

@@ -1,75 +0,0 @@
# Generated by Django 5.1.2 on 2024-11-03 19:59
import django.core.validators
from decimal import Decimal
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("parks", "0005_normalize_coordinates"),
]
operations = [
migrations.AlterField(
model_name="historicalpark",
name="latitude",
field=models.DecimalField(
blank=True,
decimal_places=6,
help_text="Latitude coordinate (-90 to 90)",
max_digits=9,
null=True,
validators=[
django.core.validators.MinValueValidator(Decimal("-90")),
django.core.validators.MaxValueValidator(Decimal("90")),
],
),
),
migrations.AlterField(
model_name="historicalpark",
name="longitude",
field=models.DecimalField(
blank=True,
decimal_places=6,
help_text="Longitude coordinate (-180 to 180)",
max_digits=10,
null=True,
validators=[
django.core.validators.MinValueValidator(Decimal("-180")),
django.core.validators.MaxValueValidator(Decimal("180")),
],
),
),
migrations.AlterField(
model_name="park",
name="latitude",
field=models.DecimalField(
blank=True,
decimal_places=6,
help_text="Latitude coordinate (-90 to 90)",
max_digits=9,
null=True,
validators=[
django.core.validators.MinValueValidator(Decimal("-90")),
django.core.validators.MaxValueValidator(Decimal("90")),
],
),
),
migrations.AlterField(
model_name="park",
name="longitude",
field=models.DecimalField(
blank=True,
decimal_places=6,
help_text="Longitude coordinate (-180 to 180)",
max_digits=10,
null=True,
validators=[
django.core.validators.MinValueValidator(Decimal("-180")),
django.core.validators.MaxValueValidator(Decimal("180")),
],
),
),
]

View File

@@ -1,27 +0,0 @@
# Generated by Django 5.1.2 on 2024-11-03 20:26
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("parks", "0006_alter_historicalpark_latitude_and_more"),
]
operations = [
migrations.RemoveField(
model_name="historicalparkarea",
name="history_user",
),
migrations.RemoveField(
model_name="historicalparkarea",
name="park",
),
migrations.DeleteModel(
name="HistoricalPark",
),
migrations.DeleteModel(
name="HistoricalParkArea",
),
]

View File

@@ -1,209 +0,0 @@
# Generated by Django 5.1.2 on 2024-11-03 20:38
import django.core.validators
import django.db.models.deletion
import history_tracking.mixins
import simple_history.models
from decimal import Decimal
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("companies", "0004_add_total_parks"),
("parks", "0007_remove_historicalparkarea_history_user_and_more"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="HistoricalPark",
fields=[
(
"id",
models.BigIntegerField(
auto_created=True, blank=True, db_index=True, verbose_name="ID"
),
),
("name", models.CharField(max_length=255)),
("slug", models.SlugField(max_length=255)),
("description", models.TextField(blank=True)),
(
"status",
models.CharField(
choices=[
("OPERATING", "Operating"),
("CLOSED_TEMP", "Temporarily Closed"),
("CLOSED_PERM", "Permanently Closed"),
("UNDER_CONSTRUCTION", "Under Construction"),
("DEMOLISHED", "Demolished"),
("RELOCATED", "Relocated"),
],
default="OPERATING",
max_length=20,
),
),
(
"latitude",
models.DecimalField(
blank=True,
decimal_places=6,
help_text="Latitude coordinate (-90 to 90)",
max_digits=9,
null=True,
validators=[
django.core.validators.MinValueValidator(Decimal("-90")),
django.core.validators.MaxValueValidator(Decimal("90")),
],
),
),
(
"longitude",
models.DecimalField(
blank=True,
decimal_places=6,
help_text="Longitude coordinate (-180 to 180)",
max_digits=10,
null=True,
validators=[
django.core.validators.MinValueValidator(Decimal("-180")),
django.core.validators.MaxValueValidator(Decimal("180")),
],
),
),
("street_address", models.CharField(blank=True, max_length=255)),
("city", models.CharField(blank=True, max_length=255)),
("state", models.CharField(blank=True, max_length=255)),
("country", models.CharField(blank=True, max_length=255)),
("postal_code", models.CharField(blank=True, max_length=20)),
("opening_date", models.DateField(blank=True, null=True)),
("closing_date", models.DateField(blank=True, null=True)),
("operating_season", models.CharField(blank=True, max_length=255)),
(
"size_acres",
models.DecimalField(
blank=True, decimal_places=2, max_digits=10, null=True
),
),
("website", models.URLField(blank=True)),
(
"average_rating",
models.DecimalField(
blank=True, decimal_places=2, max_digits=3, null=True
),
),
("total_rides", models.IntegerField(blank=True, null=True)),
("total_roller_coasters", models.IntegerField(blank=True, null=True)),
(
"created_at",
models.DateTimeField(blank=True, editable=False, null=True),
),
("updated_at", models.DateTimeField(blank=True, editable=False)),
("history_id", models.AutoField(primary_key=True, serialize=False)),
("history_date", models.DateTimeField(db_index=True)),
("history_change_reason", models.CharField(max_length=100, null=True)),
(
"history_type",
models.CharField(
choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")],
max_length=1,
),
),
(
"history_user",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="+",
to=settings.AUTH_USER_MODEL,
),
),
(
"owner",
models.ForeignKey(
blank=True,
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
to="companies.company",
),
),
],
options={
"verbose_name": "historical park",
"verbose_name_plural": "historical parks",
"ordering": ("-history_date", "-history_id"),
"get_latest_by": ("history_date", "history_id"),
},
bases=(
history_tracking.mixins.HistoricalChangeMixin,
simple_history.models.HistoricalChanges,
models.Model,
),
),
migrations.CreateModel(
name="HistoricalParkArea",
fields=[
(
"id",
models.BigIntegerField(
auto_created=True, blank=True, db_index=True, verbose_name="ID"
),
),
("name", models.CharField(max_length=255)),
("slug", models.SlugField(max_length=255)),
("description", models.TextField(blank=True)),
("opening_date", models.DateField(blank=True, null=True)),
("closing_date", models.DateField(blank=True, null=True)),
(
"created_at",
models.DateTimeField(blank=True, editable=False, null=True),
),
("updated_at", models.DateTimeField(blank=True, editable=False)),
("history_id", models.AutoField(primary_key=True, serialize=False)),
("history_date", models.DateTimeField(db_index=True)),
("history_change_reason", models.CharField(max_length=100, null=True)),
(
"history_type",
models.CharField(
choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")],
max_length=1,
),
),
(
"history_user",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="+",
to=settings.AUTH_USER_MODEL,
),
),
(
"park",
models.ForeignKey(
blank=True,
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
to="parks.park",
),
),
],
options={
"verbose_name": "historical park area",
"verbose_name_plural": "historical park areas",
"ordering": ("-history_date", "-history_id"),
"get_latest_by": ("history_date", "history_id"),
},
bases=(
history_tracking.mixins.HistoricalChangeMixin,
simple_history.models.HistoricalChanges,
models.Model,
),
),
]

View File

@@ -1,83 +0,0 @@
# Generated by Django 5.1.2 on 2024-11-04 22:21
from django.db import migrations, transaction
from django.contrib.contenttypes.models import ContentType
def forwards_func(apps, schema_editor):
"""Move park location data to Location model"""
Park = apps.get_model("parks", "Park")
Location = apps.get_model("location", "Location")
ContentType = apps.get_model("contenttypes", "ContentType")
db_alias = schema_editor.connection.alias
# Get or create content type for Park model
park_content_type, created = ContentType.objects.db_manager(db_alias).get_or_create(
app_label='parks',
model='park'
)
# Move location data for each park
with transaction.atomic():
for park in Park.objects.using(db_alias).all():
# Only create Location if park has coordinate data
if park.latitude is not None and park.longitude is not None:
Location.objects.using(db_alias).create(
content_type=park_content_type,
object_id=park.id,
name=park.name,
location_type='park',
latitude=park.latitude,
longitude=park.longitude,
street_address=park.street_address,
city=park.city,
state=park.state,
country=park.country,
postal_code=park.postal_code
)
def reverse_func(apps, schema_editor):
"""Move location data back to Park model"""
Park = apps.get_model("parks", "Park")
Location = apps.get_model("location", "Location")
ContentType = apps.get_model("contenttypes", "ContentType")
db_alias = schema_editor.connection.alias
# Get or create content type for Park model
park_content_type, created = ContentType.objects.db_manager(db_alias).get_or_create(
app_label='parks',
model='park'
)
# Move location data back to each park
with transaction.atomic():
locations = Location.objects.using(db_alias).filter(
content_type=park_content_type
)
for location in locations:
try:
park = Park.objects.using(db_alias).get(id=location.object_id)
park.latitude = location.latitude
park.longitude = location.longitude
park.street_address = location.street_address
park.city = location.city
park.state = location.state
park.country = location.country
park.postal_code = location.postal_code
park.save()
except Park.DoesNotExist:
continue
# Delete all park locations
locations.delete()
class Migration(migrations.Migration):
dependencies = [
('parks', '0008_historicalpark_historicalparkarea'),
('location', '0005_convert_coordinates_to_points'),
('contenttypes', '0002_remove_content_type_name'),
]
operations = [
migrations.RunPython(forwards_func, reverse_func, atomic=True),
]

View File

@@ -1,69 +0,0 @@
# Generated by Django 5.1.2 on 2024-11-04 22:45
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("parks", "0009_migrate_to_location_model"),
]
operations = [
migrations.RemoveField(
model_name="historicalpark",
name="latitude",
),
migrations.RemoveField(
model_name="historicalpark",
name="longitude",
),
migrations.RemoveField(
model_name="historicalpark",
name="street_address",
),
migrations.RemoveField(
model_name="historicalpark",
name="city",
),
migrations.RemoveField(
model_name="historicalpark",
name="state",
),
migrations.RemoveField(
model_name="historicalpark",
name="country",
),
migrations.RemoveField(
model_name="historicalpark",
name="postal_code",
),
migrations.RemoveField(
model_name="park",
name="latitude",
),
migrations.RemoveField(
model_name="park",
name="longitude",
),
migrations.RemoveField(
model_name="park",
name="street_address",
),
migrations.RemoveField(
model_name="park",
name="city",
),
migrations.RemoveField(
model_name="park",
name="state",
),
migrations.RemoveField(
model_name="park",
name="country",
),
migrations.RemoveField(
model_name="park",
name="postal_code",
),
]

View File

@@ -4,14 +4,16 @@ from django.utils.text import slugify
from django.contrib.contenttypes.fields import GenericRelation
from django.core.exceptions import ValidationError
from decimal import Decimal, ROUND_DOWN, InvalidOperation
from typing import Tuple, Optional, Any
from simple_history.models import HistoricalRecords
from typing import Tuple, Optional, Any, TYPE_CHECKING
from companies.models import Company
from media.models import Photo
from history_tracking.models import HistoricalModel
from location.models import Location
if TYPE_CHECKING:
from rides.models import Ride
class Park(HistoricalModel):
id: int # Type hint for Django's automatic id field
@@ -55,6 +57,8 @@ class Park(HistoricalModel):
Company, on_delete=models.SET_NULL, null=True, blank=True, related_name="parks"
)
photos = GenericRelation(Photo, related_query_name="park")
areas: models.Manager['ParkArea'] # Type hint for reverse relation
rides: models.Manager['Ride'] # Type hint for reverse relation from rides app
# Metadata
created_at = models.DateTimeField(auto_now_add=True, null=True)

View File

@@ -8,13 +8,23 @@ urlpatterns = [
# Park views
path("", views.ParkListView.as_view(), name="park_list"),
path("create/", views.ParkCreateView.as_view(), name="park_create"),
path("<slug:slug>/", views.ParkDetailView.as_view(), name="park_detail"),
path("<slug:slug>/edit/", views.ParkUpdateView.as_view(), name="park_update"),
# Add park button endpoint (moved before park detail pattern)
path("add-park-button/", views.add_park_button, name="add_park_button"),
# Location search endpoints
path("search/location/", views.location_search, name="location_search"),
path("search/reverse-geocode/", views.reverse_geocode, name="reverse_geocode"),
# Areas and search endpoints for HTMX
path("areas/", views.get_park_areas, name="get_park_areas"),
path("search/", views.search_parks, name="search_parks"),
# Park detail and related views
path("<slug:slug>/", views.ParkDetailView.as_view(), name="park_detail"),
path("<slug:slug>/edit/", views.ParkUpdateView.as_view(), name="park_update"),
path("<slug:slug>/actions/", views.park_actions, name="park_actions"),
# Area views
path("<slug:park_slug>/areas/<slug:area_slug>/", views.ParkAreaDetailView.as_view(), name="area_detail"),
@@ -26,6 +36,6 @@ urlpatterns = [
path("<slug:park_slug>/transports/", ParkSingleCategoryListView.as_view(), {'category': 'TR'}, name="park_transports"),
path("<slug:park_slug>/others/", ParkSingleCategoryListView.as_view(), {'category': 'OT'}, name="park_others"),
# Include rides URLs
# Include rides URLs with park_slug
path("<slug:park_slug>/rides/", include("rides.urls", namespace="rides")),
]

View File

@@ -1,13 +1,15 @@
from decimal import Decimal, ROUND_DOWN, InvalidOperation
from typing import Any, Optional, cast, Type
from django.views.generic import DetailView, ListView, CreateView, UpdateView
from django.shortcuts import get_object_or_404, render
from django.core.serializers.json import DjangoJSONEncoder
from django.urls import reverse
from django.db.models import Q, Avg, Count
from django.db.models import Q, Avg, Count, QuerySet, Model
from django.core.exceptions import ObjectDoesNotExist
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.contenttypes.models import ContentType
from django.contrib import messages
from django.http import JsonResponse, HttpResponseRedirect, HttpResponse
from django.http import JsonResponse, HttpResponseRedirect, HttpResponse, HttpRequest
import requests
from .models import Park, ParkArea
from .forms import ParkForm
@@ -17,11 +19,49 @@ from moderation.mixins import EditSubmissionMixin, PhotoSubmissionMixin, History
from moderation.models import EditSubmission
from media.models import Photo
from location.models import Location
from reviews.models import Review # Import the Review model
from analytics.models import PageView # Import PageView for tracking views
from reviews.models import Review
from analytics.models import PageView
def location_search(request):
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()
options = ['<option value="">No specific area</option>']
options.extend([
f'<option value="{area.id}">{area.name}</option>'
for area in areas
])
return HttpResponse('\n'.join(options))
except Park.DoesNotExist:
return HttpResponse('<option value="">Invalid park selected</option>')
def search_parks(request: HttpRequest) -> HttpResponse:
"""Search parks and return results for HTMX"""
query = request.GET.get('q', '').strip()
# If no query, show first 10 parks
if not query:
parks = Park.objects.all().order_by('name')[:10]
else:
parks = Park.objects.filter(name__icontains=query).order_by('name')[:10]
return render(request, "parks/partials/park_search_results.html", {"parks": parks})
def location_search(request: HttpRequest) -> JsonResponse:
"""Search for locations using OpenStreetMap Nominatim API"""
query = request.GET.get("q", "")
if not query:
@@ -34,8 +74,8 @@ def location_search(request):
"q": query,
"format": "json",
"addressdetails": 1,
"namedetails": 1, # Include name tags
"accept-language": "en", # Prefer English results
"namedetails": 1,
"accept-language": "en",
"limit": 10,
},
headers={"User-Agent": "ThrillWiki/1.0"},
@@ -43,16 +83,18 @@ def location_search(request):
if response.status_code == 200:
results = response.json()
# Normalize each result
normalized_results = [normalize_osm_result(result) for result in results]
# Filter out any results with invalid coordinates
valid_results = [r for r in normalized_results if r['lat'] is not None and r['lon'] is not None]
valid_results = [
r
for r in normalized_results
if r["lat"] is not None and r["lon"] is not None
]
return JsonResponse({"results": valid_results})
return JsonResponse({"results": []})
def reverse_geocode(request):
def reverse_geocode(request: HttpRequest) -> JsonResponse:
"""Reverse geocode coordinates using OpenStreetMap Nominatim API"""
try:
lat = Decimal(request.GET.get("lat", ""))
@@ -63,17 +105,18 @@ def reverse_geocode(request):
if not lat or not lon:
return JsonResponse({"error": "Missing coordinates"}, status=400)
# Normalize coordinates before geocoding
lat = lat.quantize(Decimal('0.000001'), rounding=ROUND_DOWN)
lon = lon.quantize(Decimal('0.000001'), rounding=ROUND_DOWN)
lat = lat.quantize(Decimal("0.000001"), rounding=ROUND_DOWN)
lon = lon.quantize(Decimal("0.000001"), rounding=ROUND_DOWN)
# Validate ranges
if lat < -90 or lat > 90:
return JsonResponse({"error": "Latitude must be between -90 and 90"}, status=400)
return JsonResponse(
{"error": "Latitude must be between -90 and 90"}, status=400
)
if lon < -180 or lon > 180:
return JsonResponse({"error": "Longitude must be between -180 and 180"}, status=400)
return JsonResponse(
{"error": "Longitude must be between -180 and 180"}, status=400
)
# Call Nominatim API
response = requests.get(
"https://nominatim.openstreetmap.org/reverse",
params={
@@ -81,30 +124,36 @@ def reverse_geocode(request):
"lon": str(lon),
"format": "json",
"addressdetails": 1,
"namedetails": 1, # Include name tags
"accept-language": "en", # Prefer English results
"namedetails": 1,
"accept-language": "en",
},
headers={"User-Agent": "ThrillWiki/1.0"},
)
if response.status_code == 200:
result = response.json()
# Normalize the result
normalized_result = normalize_osm_result(result)
if normalized_result['lat'] is None or normalized_result['lon'] is None:
if normalized_result["lat"] is None or normalized_result["lon"] is None:
return JsonResponse({"error": "Invalid coordinates"}, status=400)
return JsonResponse(normalized_result)
return JsonResponse({"error": "Geocoding failed"}, status=500)
def add_park_button(request: HttpRequest) -> HttpResponse:
"""Return the add park button partial template"""
return render(request, "parks/partials/add_park_button.html")
class ParkListView(ListView):
model = Park
template_name = "parks/park_list.html"
context_object_name = "parks"
def get_queryset(self):
queryset = Park.objects.select_related("owner").prefetch_related("photos", "location")
def get_queryset(self) -> QuerySet[Park]:
queryset = Park.objects.select_related("owner").prefetch_related(
"photos", "location"
)
search = self.request.GET.get("search", "").strip()
country = self.request.GET.get("country", "").strip()
@@ -114,10 +163,10 @@ class ParkListView(ListView):
if search:
queryset = queryset.filter(
Q(name__icontains=search) |
Q(location__city__icontains=search) |
Q(location__state__icontains=search) |
Q(location__country__icontains=search)
Q(name__icontains=search)
| Q(location__city__icontains=search)
| Q(location__state__icontains=search)
| Q(location__country__icontains=search)
)
if country:
@@ -132,16 +181,14 @@ class ParkListView(ListView):
if statuses:
queryset = queryset.filter(status__in=statuses)
# Annotate with ride count, coaster count, and average review rating
queryset = queryset.annotate(
ride_count=Count('rides'),
coaster_count=Count('rides', filter=Q(rides__type='coaster')),
average_rating=Avg('reviews__rating')
total_rides=Count("rides"),
total_coasters=Count("rides", filter=Q(rides__category="RC")),
)
return queryset.distinct()
def get_context_data(self, **kwargs):
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
context = super().get_context_data(**kwargs)
context["current_filters"] = {
"search": self.request.GET.get("search", ""),
@@ -152,10 +199,8 @@ class ParkListView(ListView):
}
return context
def get(self, request, *args, **kwargs):
# Check if this is an HTMX request
if hasattr(request, 'htmx') and getattr(request, 'htmx', False):
# If it is, return just the parks list partial
def get(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
if hasattr(request, "htmx") and getattr(request, "htmx", False):
self.template_name = "parks/partials/park_list.html"
return super().get(request, *args, **kwargs)
@@ -171,44 +216,43 @@ class ParkDetailView(
template_name = "parks/park_detail.html"
context_object_name = "park"
def get_object(self, queryset=None):
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)
# Try to get by current or historical slug
return Park.get_by_slug(slug)[0]
if slug is None:
raise ObjectDoesNotExist("No slug provided")
park, _ = Park.get_by_slug(slug)
return park
def get_queryset(self):
return super().get_queryset().prefetch_related(
'rides',
'rides__manufacturer',
'photos',
'areas',
'location'
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):
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
context = super().get_context_data(**kwargs)
context["areas"] = self.object.areas.all()
# Get rides ordered by status (operating first) and name
context["rides"] = self.object.rides.all().order_by(
'-status', # OPERATING will come before others
'name'
)
park = cast(Park, self.object)
context["areas"] = park.areas.all()
context["rides"] = park.rides.all().order_by("-status", "name")
# Check if the user has reviewed the park
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=self.object.id
object_id=park.id,
).exists()
else:
context["has_reviewed"] = False
return context
def get_redirect_url_pattern(self):
def get_redirect_url_pattern(self) -> str:
return "parks:park_detail"
@@ -217,38 +261,36 @@ class ParkCreateView(LoginRequiredMixin, CreateView):
form_class = ParkForm
template_name = "parks/park_form.html"
def prepare_changes_data(self, cleaned_data):
def prepare_changes_data(self, cleaned_data: dict[str, Any]) -> dict[str, Any]:
data = cleaned_data.copy()
# Convert model instances to IDs for JSON serialization
if data.get("owner"):
data["owner"] = data["owner"].id
# Convert dates to ISO format strings
if data.get("opening_date"):
data["opening_date"] = data["opening_date"].isoformat()
if data.get("closing_date"):
data["closing_date"] = data["closing_date"].isoformat()
# Convert Decimal fields to strings
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):
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)
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)
form.cleaned_data["longitude"] = lon.quantize(
Decimal("0.000001"), rounding=ROUND_DOWN
)
def form_valid(self, form):
# Normalize coordinates before saving
def form_valid(self, form: ParkForm) -> HttpResponse:
self.normalize_coordinates(form)
changes = self.prepare_changes_data(form.cleaned_data)
# Create submission record
submission = EditSubmission.objects.create(
user=self.request.user,
content_type=ContentType.objects.get_for_model(Park),
@@ -258,8 +300,9 @@ class ParkCreateView(LoginRequiredMixin, CreateView):
source=self.request.POST.get("source", ""),
)
# If user is moderator or above, auto-approve
if hasattr(self.request.user, 'role') and getattr(self.request.user, 'role', None) in ["MODERATOR", "ADMIN", "SUPERUSER"]:
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
@@ -267,23 +310,23 @@ class ParkCreateView(LoginRequiredMixin, CreateView):
submission.handled_by = self.request.user
submission.save()
# Create Location record
if form.cleaned_data.get("latitude") and form.cleaned_data.get("longitude"):
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',
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", "")
postal_code=form.cleaned_data.get("postal_code", ""),
)
# Handle photo uploads
photos = self.request.FILES.getlist("photos")
for photo_file in photos:
try:
@@ -319,7 +362,7 @@ class ParkCreateView(LoginRequiredMixin, CreateView):
)
return HttpResponseRedirect(reverse("parks:park_list"))
def form_invalid(self, form):
def form_invalid(self, form: ParkForm) -> HttpResponse:
messages.error(
self.request,
"Please correct the errors below. Required fields are marked with an asterisk (*).",
@@ -329,7 +372,7 @@ class ParkCreateView(LoginRequiredMixin, CreateView):
messages.error(self.request, f"{field}: {error}")
return super().form_invalid(form)
def get_success_url(self):
def get_success_url(self) -> str:
return reverse("parks:park_detail", kwargs={"slug": self.object.slug})
@@ -338,43 +381,41 @@ class ParkUpdateView(LoginRequiredMixin, UpdateView):
form_class = ParkForm
template_name = "parks/park_form.html"
def get_context_data(self, **kwargs):
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):
def prepare_changes_data(self, cleaned_data: dict[str, Any]) -> dict[str, Any]:
data = cleaned_data.copy()
# Convert model instances to IDs for JSON serialization
if data.get("owner"):
data["owner"] = data["owner"].id
# Convert dates to ISO format strings
if data.get("opening_date"):
data["opening_date"] = data["opening_date"].isoformat()
if data.get("closing_date"):
data["closing_date"] = data["closing_date"].isoformat()
# Convert Decimal fields to strings
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):
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)
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)
form.cleaned_data["longitude"] = lon.quantize(
Decimal("0.000001"), rounding=ROUND_DOWN
)
def form_valid(self, form):
# Normalize coordinates before saving
def form_valid(self, form: ParkForm) -> HttpResponse:
self.normalize_coordinates(form)
changes = self.prepare_changes_data(form.cleaned_data)
# Create submission record
submission = EditSubmission.objects.create(
user=self.request.user,
content_type=ContentType.objects.get_for_model(Park),
@@ -385,25 +426,25 @@ class ParkUpdateView(LoginRequiredMixin, UpdateView):
source=self.request.POST.get("source", ""),
)
# If user is moderator or above, auto-approve
if hasattr(self.request.user, 'role') and getattr(self.request.user, 'role', None) in ["MODERATOR", "ADMIN", "SUPERUSER"]:
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()
# Update or create Location record
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", "")
"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():
@@ -415,10 +456,9 @@ class ParkUpdateView(LoginRequiredMixin, UpdateView):
Location.objects.create(
content_type=ContentType.objects.get_for_model(Park),
object_id=self.object.id,
**location_data
**location_data,
)
# Handle photo uploads
photos = self.request.FILES.getlist("photos")
uploaded_count = 0
for photo_file in photos:
@@ -458,7 +498,7 @@ class ParkUpdateView(LoginRequiredMixin, UpdateView):
reverse("parks:park_detail", kwargs={"slug": self.object.slug})
)
def form_invalid(self, form):
def form_invalid(self, form: ParkForm) -> HttpResponse:
messages.error(
self.request,
"Please correct the errors below. Required fields are marked with an asterisk (*).",
@@ -468,7 +508,7 @@ class ParkUpdateView(LoginRequiredMixin, UpdateView):
messages.error(self.request, f"{field}: {error}")
return super().form_invalid(form)
def get_success_url(self):
def get_success_url(self) -> str:
return reverse("parks:park_detail", kwargs={"slug": self.object.slug})
@@ -484,23 +524,25 @@ class ParkAreaDetailView(
context_object_name = "area"
slug_url_kwarg = "area_slug"
def get_object(self, queryset=None):
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")
# Try to get by current or historical slug
obj, is_old_slug = ParkArea.get_by_slug(area_slug)
if obj.park.slug != park_slug:
raise self.model.DoesNotExist("Park slug doesn't match")
return obj
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):
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
context = super().get_context_data(**kwargs)
return context
def get_redirect_url_pattern(self):
def get_redirect_url_pattern(self) -> str:
return "parks:park_detail"
def get_redirect_url_kwargs(self):
return {"park_slug": self.object.park.slug, "area_slug": self.object.slug}
def get_redirect_url_kwargs(self) -> dict[str, str]:
area = cast(ParkArea, self.object)
return {"park_slug": area.park.slug, "area_slug": area.slug}

View File

@@ -1,4 +1,4 @@
# Generated by Django 5.1.2 on 2024-10-28 20:17
# Generated by Django 5.1.3 on 2024-11-12 18:07
import django.core.validators
import django.db.models.deletion
@@ -11,78 +11,187 @@ class Migration(migrations.Migration):
initial = True
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
("contenttypes", "0002_remove_content_type_name"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Review',
name="Review",
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('object_id', models.PositiveIntegerField()),
('rating', models.PositiveSmallIntegerField(validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(10)])),
('title', models.CharField(max_length=200)),
('content', models.TextField()),
('visit_date', models.DateField()),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('is_published', models.BooleanField(default=True)),
('moderation_notes', models.TextField(blank=True)),
('moderated_at', models.DateTimeField(blank=True, null=True)),
('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')),
('moderated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='moderated_reviews', to=settings.AUTH_USER_MODEL)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reviews', to=settings.AUTH_USER_MODEL)),
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("object_id", models.PositiveIntegerField()),
(
"rating",
models.PositiveSmallIntegerField(
validators=[
django.core.validators.MinValueValidator(1),
django.core.validators.MaxValueValidator(10),
]
),
),
("title", models.CharField(max_length=200)),
("content", models.TextField()),
("visit_date", models.DateField()),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("is_published", models.BooleanField(default=True)),
("moderation_notes", models.TextField(blank=True)),
("moderated_at", models.DateTimeField(blank=True, null=True)),
(
"content_type",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="contenttypes.contenttype",
),
),
(
"moderated_by",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="moderated_reviews",
to=settings.AUTH_USER_MODEL,
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="reviews",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
'ordering': ['-created_at'],
"ordering": ["-created_at"],
},
),
migrations.CreateModel(
name='ReviewImage',
name="ReviewImage",
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('image', models.ImageField(upload_to='review_images/')),
('caption', models.CharField(blank=True, max_length=200)),
('order', models.PositiveIntegerField(default=0)),
('review', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='images', to='reviews.review')),
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("image", models.ImageField(upload_to="review_images/")),
("caption", models.CharField(blank=True, max_length=200)),
("order", models.PositiveIntegerField(default=0)),
(
"review",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="images",
to="reviews.review",
),
),
],
options={
'ordering': ['order'],
"ordering": ["order"],
},
),
migrations.CreateModel(
name='ReviewLike',
name="ReviewLike",
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
('review', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='likes', to='reviews.review')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='review_likes', to=settings.AUTH_USER_MODEL)),
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
(
"review",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="likes",
to="reviews.review",
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="review_likes",
to=settings.AUTH_USER_MODEL,
),
),
],
),
migrations.CreateModel(
name='ReviewReport',
name="ReviewReport",
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('reason', models.TextField()),
('created_at', models.DateTimeField(auto_now_add=True)),
('resolved', models.BooleanField(default=False)),
('resolution_notes', models.TextField(blank=True)),
('resolved_at', models.DateTimeField(blank=True, null=True)),
('resolved_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='resolved_review_reports', to=settings.AUTH_USER_MODEL)),
('review', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reports', to='reviews.review')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='review_reports', to=settings.AUTH_USER_MODEL)),
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("reason", models.TextField()),
("created_at", models.DateTimeField(auto_now_add=True)),
("resolved", models.BooleanField(default=False)),
("resolution_notes", models.TextField(blank=True)),
("resolved_at", models.DateTimeField(blank=True, null=True)),
(
"resolved_by",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="resolved_review_reports",
to=settings.AUTH_USER_MODEL,
),
),
(
"review",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="reports",
to="reviews.review",
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="review_reports",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
'ordering': ['-created_at'],
"ordering": ["-created_at"],
},
),
migrations.AddIndex(
model_name='review',
index=models.Index(fields=['content_type', 'object_id'], name='reviews_rev_content_627d80_idx'),
model_name="review",
index=models.Index(
fields=["content_type", "object_id"],
name="reviews_rev_content_627d80_idx",
),
),
migrations.AlterUniqueTogether(
name='reviewlike',
unique_together={('review', 'user')},
name="reviewlike",
unique_together={("review", "user")},
),
]

View File

View File

@@ -0,0 +1,11 @@
from django import template
from reviews.models import Review
register = template.Library()
@register.filter
def has_reviewed_park(user, park):
"""Check if a user has reviewed a park"""
if not user.is_authenticated:
return False
return Review.objects.filter(user=user, content_type__model='park', object_id=park.id).exists()

View File

@@ -1,8 +1,9 @@
from django.apps import AppConfig
class RidesConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'rides'
def ready(self):
import rides.signals # noqa
import rides.signals

View File

@@ -1,74 +1,283 @@
from django import forms
from .models import Ride
from django.forms import ModelChoiceField
from django.urls import reverse_lazy
from .models import Ride, RideModel
from parks.models import Park, ParkArea
from companies.models import Manufacturer, Designer
class RideForm(forms.ModelForm):
park_search = forms.CharField(
label="Park *",
required=True,
widget=forms.TextInput(
attrs={
"class": "w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white",
"placeholder": "Search for a park...",
"hx-get": "/parks/search/",
"hx-trigger": "click, input delay:200ms",
"hx-target": "#park-search-results",
"name": "q",
"autocomplete": "off",
}
),
)
manufacturer_search = forms.CharField(
label="Manufacturer",
required=False,
widget=forms.TextInput(
attrs={
"class": "w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white",
"placeholder": "Search for a manufacturer...",
"hx-get": reverse_lazy("rides:search_manufacturers"),
"hx-trigger": "click, input delay:200ms",
"hx-target": "#manufacturer-search-results",
"name": "q",
"autocomplete": "off",
}
),
)
designer_search = forms.CharField(
label="Designer",
required=False,
widget=forms.TextInput(
attrs={
"class": "w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white",
"placeholder": "Search for a designer...",
"hx-get": reverse_lazy("rides:search_designers"),
"hx-trigger": "click, input delay:200ms",
"hx-target": "#designer-search-results",
"name": "q",
"autocomplete": "off",
}
),
)
ride_model_search = forms.CharField(
label="Ride Model",
required=False,
widget=forms.TextInput(
attrs={
"class": "w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white",
"placeholder": "Search for a ride model...",
"hx-get": reverse_lazy("rides:search_ride_models"),
"hx-trigger": "click, input delay:200ms",
"hx-target": "#ride-model-search-results",
"hx-include": "[name='manufacturer']",
"name": "q",
"autocomplete": "off",
}
),
)
park = forms.ModelChoiceField(
queryset=Park.objects.all(),
required=True,
label="",
widget=forms.HiddenInput()
)
manufacturer = forms.ModelChoiceField(
queryset=Manufacturer.objects.all(),
required=False,
label="",
widget=forms.HiddenInput()
)
designer = forms.ModelChoiceField(
queryset=Designer.objects.all(),
required=False,
label="",
widget=forms.HiddenInput()
)
ride_model = forms.ModelChoiceField(
queryset=RideModel.objects.all(),
required=False,
label="",
widget=forms.HiddenInput()
)
park_area = ModelChoiceField(
queryset=ParkArea.objects.none(),
required=False,
widget=forms.Select(
attrs={
"class": "w-full border-gray-300 rounded-lg form-select dark:border-gray-600 dark:bg-gray-700 dark:text-white",
"placeholder": "Select an area within the park..."
}
),
)
class Meta:
model = Ride
fields = ['name', 'park_area', 'category', 'manufacturer', 'designer', 'model_name', 'status',
'opening_date', 'closing_date', 'status_since', 'min_height_in', 'max_height_in',
'accessibility_options', 'capacity_per_hour', 'ride_duration_seconds', 'description']
fields = [
"name",
"category",
"manufacturer",
"designer",
"ride_model",
"status",
"post_closing_status",
"opening_date",
"closing_date",
"status_since",
"min_height_in",
"max_height_in",
"capacity_per_hour",
"ride_duration_seconds",
"description",
]
widgets = {
'name': forms.TextInput(attrs={
'class': 'w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white'
}),
'park_area': forms.Select(attrs={
'class': 'w-full border-gray-300 rounded-lg form-select dark:border-gray-600 dark:bg-gray-700 dark:text-white'
}),
'category': forms.Select(attrs={
'class': 'w-full border-gray-300 rounded-lg form-select dark:border-gray-600 dark:bg-gray-700 dark:text-white'
}),
'manufacturer': forms.Select(attrs={
'class': 'w-full border-gray-300 rounded-lg form-select dark:border-gray-600 dark:bg-gray-700 dark:text-white'
}),
'designer': forms.Select(attrs={
'class': 'w-full border-gray-300 rounded-lg form-select dark:border-gray-600 dark:bg-gray-700 dark:text-white'
}),
'model_name': forms.TextInput(attrs={
'class': 'w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white'
}),
'status': forms.Select(attrs={
'class': 'w-full border-gray-300 rounded-lg form-select dark:border-gray-600 dark:bg-gray-700 dark:text-white'
}),
'opening_date': forms.DateInput(attrs={
'type': 'date',
'class': 'w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white'
}),
'closing_date': forms.DateInput(attrs={
'type': 'date',
'class': 'w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white'
}),
'status_since': forms.DateInput(attrs={
'type': 'date',
'class': 'w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white'
}),
'min_height_in': forms.NumberInput(attrs={
'class': 'w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white',
'min': '0'
}),
'max_height_in': forms.NumberInput(attrs={
'class': 'w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white',
'min': '0'
}),
'accessibility_options': forms.TextInput(attrs={
'class': 'w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white'
}),
'capacity_per_hour': forms.NumberInput(attrs={
'class': 'w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white',
'min': '0'
}),
'ride_duration_seconds': forms.NumberInput(attrs={
'class': 'w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white',
'min': '0'
}),
'description': forms.Textarea(attrs={
'rows': 4,
'class': 'w-full border-gray-300 rounded-lg form-textarea dark:border-gray-600 dark:bg-gray-700 dark:text-white'
}),
"name": forms.TextInput(
attrs={
"class": "w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white",
"placeholder": "Official name of the ride"
}
),
"category": forms.Select(
attrs={
"class": "w-full border-gray-300 rounded-lg form-select dark:border-gray-600 dark:bg-gray-700 dark:text-white",
"hx-get": reverse_lazy("rides:coaster_fields"),
"hx-target": "#coaster-fields",
"hx-trigger": "change",
"hx-include": "this",
"hx-swap": "innerHTML"
}
),
"status": forms.Select(
attrs={
"class": "w-full border-gray-300 rounded-lg form-select dark:border-gray-600 dark:bg-gray-700 dark:text-white",
"placeholder": "Current operational status",
"x-model": "status",
"@change": "handleStatusChange"
}
),
"post_closing_status": forms.Select(
attrs={
"class": "w-full border-gray-300 rounded-lg form-select dark:border-gray-600 dark:bg-gray-700 dark:text-white",
"placeholder": "Status after closing",
"x-show": "status === 'CLOSING'"
}
),
"opening_date": forms.DateInput(
attrs={
"type": "date",
"class": "w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white",
"placeholder": "Date when ride first opened"
}
),
"closing_date": forms.DateInput(
attrs={
"type": "date",
"class": "w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white",
"placeholder": "Date when ride will close",
"x-show": "['CLOSING', 'SBNO', 'CLOSED_PERM', 'DEMOLISHED', 'RELOCATED'].includes(status)",
":required": "status === 'CLOSING'"
}
),
"status_since": forms.DateInput(
attrs={
"type": "date",
"class": "w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white",
"placeholder": "Date when current status took effect"
}
),
"min_height_in": forms.NumberInput(
attrs={
"class": "w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white",
"min": "0",
"placeholder": "Minimum height requirement in inches"
}
),
"max_height_in": forms.NumberInput(
attrs={
"class": "w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white",
"min": "0",
"placeholder": "Maximum height limit in inches (if applicable)"
}
),
"capacity_per_hour": forms.NumberInput(
attrs={
"class": "w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white",
"min": "0",
"placeholder": "Theoretical hourly ride capacity"
}
),
"ride_duration_seconds": forms.NumberInput(
attrs={
"class": "w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white",
"min": "0",
"placeholder": "Total duration of one ride cycle in seconds"
}
),
"description": forms.Textarea(
attrs={
"rows": 4,
"class": "w-full border-gray-300 rounded-lg form-textarea dark:border-gray-600 dark:bg-gray-700 dark:text-white",
"placeholder": "General description and notable features of the ride"
}
),
}
def __init__(self, *args, **kwargs):
park = kwargs.pop('park', None)
park = kwargs.pop("park", None)
super().__init__(*args, **kwargs)
# Make category required
self.fields['category'].required = True
# Clear any default values for date fields
self.fields["opening_date"].initial = None
self.fields["closing_date"].initial = None
self.fields["status_since"].initial = None
# Move fields to the beginning in desired order
field_order = [
"park_search", "park", "park_area",
"name", "manufacturer_search", "manufacturer",
"designer_search", "designer", "ride_model_search",
"ride_model", "category", "status",
"post_closing_status", "opening_date", "closing_date", "status_since",
"min_height_in", "max_height_in", "capacity_per_hour",
"ride_duration_seconds", "description"
]
self.order_fields(field_order)
if park:
# Filter park_area choices to only show areas from the current park
self.fields['park_area'].queryset = park.areas.all()
# If park is provided, set it as the initial value
self.fields["park"].initial = park
# Hide the park search field since we know the park
del self.fields["park_search"]
# Create new park_area field with park's areas
self.fields["park_area"] = forms.ModelChoiceField(
queryset=park.areas.all(),
required=False,
widget=forms.Select(
attrs={
"class": "w-full border-gray-300 rounded-lg form-select dark:border-gray-600 dark:bg-gray-700 dark:text-white",
"placeholder": "Select an area within the park..."
}
),
)
else:
# If no park provided, show park search and disable park_area until park is selected
self.fields["park_area"].widget.attrs["disabled"] = True
# Initialize park search with current park name if editing
if self.instance and self.instance.pk and self.instance.park:
self.fields["park_search"].initial = self.instance.park.name
self.fields["park"].initial = self.instance.park
# Initialize manufacturer, designer, and ride model search fields if editing
if self.instance and self.instance.pk:
if self.instance.manufacturer:
self.fields["manufacturer_search"].initial = self.instance.manufacturer.name
self.fields["manufacturer"].initial = self.instance.manufacturer
if self.instance.designer:
self.fields["designer_search"].initial = self.instance.designer.name
self.fields["designer"].initial = self.instance.designer
if self.instance.ride_model:
self.fields["ride_model_search"].initial = self.instance.ride_model.name
self.fields["ride_model"].initial = self.instance.ride_model

View File

@@ -1,4 +1,4 @@
# Generated by Django 5.1.2 on 2024-10-28 21:53
# Generated by Django 5.1.3 on 2024-11-12 18:07
import django.db.models.deletion
import simple_history.models
@@ -11,7 +11,8 @@ class Migration(migrations.Migration):
initial = True
dependencies = [
("companies", "0002_stats_fields"),
("companies", "0001_initial"),
("designers", "0001_initial"),
("parks", "0001_initial"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
@@ -20,12 +21,7 @@ class Migration(migrations.Migration):
migrations.CreateModel(
name="HistoricalRide",
fields=[
(
"id",
models.BigIntegerField(
auto_created=True, blank=True, db_index=True, verbose_name="ID"
),
),
("id", models.BigIntegerField(blank=True, db_index=True)),
("name", models.CharField(max_length=255)),
("slug", models.SlugField(max_length=255)),
("description", models.TextField(blank=True)),
@@ -92,6 +88,18 @@ class Migration(migrations.Migration):
max_length=1,
),
),
(
"designer",
models.ForeignKey(
blank=True,
db_constraint=False,
help_text="The designer/engineering firm responsible for the ride",
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
to="designers.designer",
),
),
(
"history_user",
models.ForeignKey(
@@ -146,15 +154,7 @@ class Migration(migrations.Migration):
migrations.CreateModel(
name="Ride",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("id", models.BigAutoField(primary_key=True, serialize=False)),
("name", models.CharField(max_length=255)),
("slug", models.SlugField(max_length=255)),
("description", models.TextField(blank=True)),
@@ -212,12 +212,20 @@ class Migration(migrations.Migration):
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"manufacturer",
"designer",
models.ForeignKey(
blank=True,
help_text="The designer/engineering firm responsible for the ride",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="rides",
to="designers.designer",
),
),
(
"manufacturer",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="companies.manufacturer",
),
),
@@ -248,12 +256,7 @@ class Migration(migrations.Migration):
migrations.CreateModel(
name="HistoricalRollerCoasterStats",
fields=[
(
"id",
models.BigIntegerField(
auto_created=True, blank=True, db_index=True, verbose_name="ID"
),
),
("id", models.BigIntegerField(blank=True, db_index=True)),
(
"height_ft",
models.DecimalField(
@@ -278,6 +281,57 @@ class Migration(migrations.Migration):
models.PositiveIntegerField(blank=True, null=True),
),
("track_type", models.CharField(blank=True, max_length=255)),
(
"track_material",
models.CharField(
blank=True,
choices=[
("STEEL", "Steel"),
("WOOD", "Wood"),
("HYBRID", "Hybrid"),
("OTHER", "Other"),
],
default="STEEL",
max_length=20,
),
),
(
"roller_coaster_type",
models.CharField(
blank=True,
choices=[
("SITDOWN", "Sit-Down"),
("INVERTED", "Inverted"),
("FLYING", "Flying"),
("STANDUP", "Stand-Up"),
("WING", "Wing"),
("SUSPENDED", "Suspended"),
("BOBSLED", "Bobsled"),
("PIPELINE", "Pipeline"),
("MOTORBIKE", "Motorbike"),
("FLOORLESS", "Floorless"),
("DIVE", "Dive"),
("FAMILY", "Family"),
("WILD_MOUSE", "Wild Mouse"),
("SPINNING", "Spinning"),
("FOURTH_DIMENSION", "4th Dimension"),
("OTHER", "Other"),
],
default="SITDOWN",
help_text="The type/style of roller coaster (e.g. Sit-Down, Inverted, Flying)",
max_length=20,
),
),
(
"max_drop_height_ft",
models.DecimalField(
blank=True,
decimal_places=2,
help_text="Maximum vertical drop height in feet",
max_digits=6,
null=True,
),
),
(
"launch_type",
models.CharField(
@@ -340,15 +394,7 @@ class Migration(migrations.Migration):
migrations.CreateModel(
name="RollerCoasterStats",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("id", models.BigAutoField(primary_key=True, serialize=False)),
(
"height_ft",
models.DecimalField(
@@ -373,6 +419,57 @@ class Migration(migrations.Migration):
models.PositiveIntegerField(blank=True, null=True),
),
("track_type", models.CharField(blank=True, max_length=255)),
(
"track_material",
models.CharField(
blank=True,
choices=[
("STEEL", "Steel"),
("WOOD", "Wood"),
("HYBRID", "Hybrid"),
("OTHER", "Other"),
],
default="STEEL",
max_length=20,
),
),
(
"roller_coaster_type",
models.CharField(
blank=True,
choices=[
("SITDOWN", "Sit-Down"),
("INVERTED", "Inverted"),
("FLYING", "Flying"),
("STANDUP", "Stand-Up"),
("WING", "Wing"),
("SUSPENDED", "Suspended"),
("BOBSLED", "Bobsled"),
("PIPELINE", "Pipeline"),
("MOTORBIKE", "Motorbike"),
("FLOORLESS", "Floorless"),
("DIVE", "Dive"),
("FAMILY", "Family"),
("WILD_MOUSE", "Wild Mouse"),
("SPINNING", "Spinning"),
("FOURTH_DIMENSION", "4th Dimension"),
("OTHER", "Other"),
],
default="SITDOWN",
help_text="The type/style of roller coaster (e.g. Sit-Down, Inverted, Flying)",
max_length=20,
),
),
(
"max_drop_height_ft",
models.DecimalField(
blank=True,
decimal_places=2,
help_text="Maximum vertical drop height in feet",
max_digits=6,
null=True,
),
),
(
"launch_type",
models.CharField(

View File

@@ -1,4 +1,4 @@
# Generated by Django 5.1.2 on 2024-11-04 00:28
# Generated by Django 5.1.3 on 2024-11-12 20:23
import django.db.models.deletion
from django.db import migrations, models
@@ -7,12 +7,12 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("designers", "0001_initial"),
("rides", "0004_historicalrollercoasterstats_roller_coaster_type_and_more"),
("companies", "0002_add_designer_model"),
("rides", "0001_initial"),
]
operations = [
migrations.AddField(
migrations.AlterField(
model_name="historicalride",
name="designer",
field=models.ForeignKey(
@@ -22,10 +22,10 @@ class Migration(migrations.Migration):
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
to="designers.designer",
to="companies.designer",
),
),
migrations.AddField(
migrations.AlterField(
model_name="ride",
name="designer",
field=models.ForeignKey(
@@ -34,7 +34,7 @@ class Migration(migrations.Migration):
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="rides",
to="designers.designer",
to="companies.designer",
),
),
]

View File

@@ -1,25 +0,0 @@
# Generated by Django 5.1.2 on 2024-10-29 02:02
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("companies", "0004_add_total_parks"),
("rides", "0001_initial"),
]
operations = [
migrations.AlterField(
model_name="ride",
name="manufacturer",
field=models.ForeignKey(
default=1,
on_delete=django.db.models.deletion.CASCADE,
to="companies.manufacturer",
),
preserve_default=False,
),
]

View File

@@ -1,65 +0,0 @@
# Generated by Django 5.1.2 on 2024-11-04 00:17
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("rides", "0002_alter_ride_manufacturer"),
]
operations = [
migrations.AddField(
model_name="historicalrollercoasterstats",
name="max_drop_height_ft",
field=models.DecimalField(
blank=True,
decimal_places=2,
help_text="Maximum vertical drop height in feet",
max_digits=6,
null=True,
),
),
migrations.AddField(
model_name="historicalrollercoasterstats",
name="track_material",
field=models.CharField(
blank=True,
choices=[
("STEEL", "Steel"),
("WOOD", "Wood"),
("HYBRID", "Hybrid"),
("OTHER", "Other"),
],
default="STEEL",
max_length=20,
),
),
migrations.AddField(
model_name="rollercoasterstats",
name="max_drop_height_ft",
field=models.DecimalField(
blank=True,
decimal_places=2,
help_text="Maximum vertical drop height in feet",
max_digits=6,
null=True,
),
),
migrations.AddField(
model_name="rollercoasterstats",
name="track_material",
field=models.CharField(
blank=True,
choices=[
("STEEL", "Steel"),
("WOOD", "Wood"),
("HYBRID", "Hybrid"),
("OTHER", "Other"),
],
default="STEEL",
max_length=20,
),
),
]

View File

@@ -0,0 +1,160 @@
# Generated by Django 5.1.3 on 2024-11-12 21:40
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("companies", "0002_add_designer_model"),
("rides", "0002_alter_historicalride_designer_alter_ride_designer"),
]
operations = [
migrations.RemoveField(
model_name="historicalride",
name="accessibility_options",
),
migrations.RemoveField(
model_name="ride",
name="accessibility_options",
),
migrations.AlterField(
model_name="historicalride",
name="category",
field=models.CharField(
blank=True,
choices=[
("", "Select ride type... *"),
("RC", "Roller Coaster"),
("DR", "Dark Ride"),
("FR", "Flat Ride"),
("WR", "Water Ride"),
("TR", "Transport"),
("OT", "Other"),
],
default="",
max_length=2,
),
),
migrations.AlterField(
model_name="historicalride",
name="designer",
field=models.ForeignKey(
blank=True,
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
to="companies.designer",
),
),
migrations.AlterField(
model_name="historicalrollercoasterstats",
name="max_drop_height_ft",
field=models.DecimalField(
blank=True, decimal_places=2, max_digits=6, null=True
),
),
migrations.AlterField(
model_name="historicalrollercoasterstats",
name="roller_coaster_type",
field=models.CharField(
blank=True,
choices=[
("SITDOWN", "Sit-Down"),
("INVERTED", "Inverted"),
("FLYING", "Flying"),
("STANDUP", "Stand-Up"),
("WING", "Wing"),
("SUSPENDED", "Suspended"),
("BOBSLED", "Bobsled"),
("PIPELINE", "Pipeline"),
("MOTORBIKE", "Motorbike"),
("FLOORLESS", "Floorless"),
("DIVE", "Dive"),
("FAMILY", "Family"),
("WILD_MOUSE", "Wild Mouse"),
("SPINNING", "Spinning"),
("FOURTH_DIMENSION", "4th Dimension"),
("OTHER", "Other"),
],
default="SITDOWN",
max_length=20,
),
),
migrations.AlterField(
model_name="ride",
name="category",
field=models.CharField(
blank=True,
choices=[
("", "Select ride type... *"),
("RC", "Roller Coaster"),
("DR", "Dark Ride"),
("FR", "Flat Ride"),
("WR", "Water Ride"),
("TR", "Transport"),
("OT", "Other"),
],
default="",
max_length=2,
),
),
migrations.AlterField(
model_name="ride",
name="designer",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="rides",
to="companies.designer",
),
),
migrations.AlterField(
model_name="ride",
name="manufacturer",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="companies.manufacturer",
),
),
migrations.AlterField(
model_name="rollercoasterstats",
name="max_drop_height_ft",
field=models.DecimalField(
blank=True, decimal_places=2, max_digits=6, null=True
),
),
migrations.AlterField(
model_name="rollercoasterstats",
name="roller_coaster_type",
field=models.CharField(
blank=True,
choices=[
("SITDOWN", "Sit-Down"),
("INVERTED", "Inverted"),
("FLYING", "Flying"),
("STANDUP", "Stand-Up"),
("WING", "Wing"),
("SUSPENDED", "Suspended"),
("BOBSLED", "Bobsled"),
("PIPELINE", "Pipeline"),
("MOTORBIKE", "Motorbike"),
("FLOORLESS", "Floorless"),
("DIVE", "Dive"),
("FAMILY", "Family"),
("WILD_MOUSE", "Wild Mouse"),
("SPINNING", "Spinning"),
("FOURTH_DIMENSION", "4th Dimension"),
("OTHER", "Other"),
],
default="SITDOWN",
max_length=20,
),
),
]

View File

@@ -0,0 +1,49 @@
# Generated by Django 5.1.3 on 2024-11-12 21:54
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("rides", "0003_remove_historicalride_accessibility_options_and_more"),
]
operations = [
migrations.AlterField(
model_name="historicalride",
name="category",
field=models.CharField(
blank=True,
choices=[
("", "Select ride type"),
("RC", "Roller Coaster"),
("DR", "Dark Ride"),
("FR", "Flat Ride"),
("WR", "Water Ride"),
("TR", "Transport"),
("OT", "Other"),
],
default="",
max_length=2,
),
),
migrations.AlterField(
model_name="ride",
name="category",
field=models.CharField(
blank=True,
choices=[
("", "Select ride type"),
("RC", "Roller Coaster"),
("DR", "Dark Ride"),
("FR", "Flat Ride"),
("WR", "Water Ride"),
("TR", "Transport"),
("OT", "Other"),
],
default="",
max_length=2,
),
),
]

View File

@@ -1,69 +0,0 @@
# Generated by Django 5.1.2 on 2024-11-04 00:21
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("rides", "0003_historicalrollercoasterstats_max_drop_height_ft_and_more"),
]
operations = [
migrations.AddField(
model_name="historicalrollercoasterstats",
name="roller_coaster_type",
field=models.CharField(
blank=True,
choices=[
("SITDOWN", "Sit-Down"),
("INVERTED", "Inverted"),
("FLYING", "Flying"),
("STANDUP", "Stand-Up"),
("WING", "Wing"),
("SUSPENDED", "Suspended"),
("BOBSLED", "Bobsled"),
("PIPELINE", "Pipeline"),
("MOTORBIKE", "Motorbike"),
("FLOORLESS", "Floorless"),
("DIVE", "Dive"),
("FAMILY", "Family"),
("WILD_MOUSE", "Wild Mouse"),
("SPINNING", "Spinning"),
("FOURTH_DIMENSION", "4th Dimension"),
("OTHER", "Other"),
],
default="SITDOWN",
help_text="The type/style of roller coaster (e.g. Sit-Down, Inverted, Flying)",
max_length=20,
),
),
migrations.AddField(
model_name="rollercoasterstats",
name="roller_coaster_type",
field=models.CharField(
blank=True,
choices=[
("SITDOWN", "Sit-Down"),
("INVERTED", "Inverted"),
("FLYING", "Flying"),
("STANDUP", "Stand-Up"),
("WING", "Wing"),
("SUSPENDED", "Suspended"),
("BOBSLED", "Bobsled"),
("PIPELINE", "Pipeline"),
("MOTORBIKE", "Motorbike"),
("FLOORLESS", "Floorless"),
("DIVE", "Dive"),
("FAMILY", "Family"),
("WILD_MOUSE", "Wild Mouse"),
("SPINNING", "Spinning"),
("FOURTH_DIMENSION", "4th Dimension"),
("OTHER", "Other"),
],
default="SITDOWN",
help_text="The type/style of roller coaster (e.g. Sit-Down, Inverted, Flying)",
max_length=20,
),
),
]

View File

@@ -0,0 +1,259 @@
# Generated by Django 5.1.3 on 2024-11-12 22:27
import django.db.models.deletion
import simple_history.models
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("companies", "0002_add_designer_model"),
("rides", "0004_alter_historicalride_category_alter_ride_category"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterField(
model_name="rollercoasterstats",
name="id",
field=models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
migrations.AlterField(
model_name="rollercoasterstats",
name="launch_type",
field=models.CharField(
choices=[
("CHAIN", "Chain Lift"),
("LSM", "LSM Launch"),
("HYDRAULIC", "Hydraulic Launch"),
("GRAVITY", "Gravity"),
("OTHER", "Other"),
],
default="CHAIN",
max_length=20,
),
),
migrations.AlterField(
model_name="rollercoasterstats",
name="roller_coaster_type",
field=models.CharField(
blank=True,
choices=[
("SITDOWN", "Sit Down"),
("INVERTED", "Inverted"),
("FLYING", "Flying"),
("STANDUP", "Stand Up"),
("WING", "Wing"),
("DIVE", "Dive"),
("FAMILY", "Family"),
("WILD_MOUSE", "Wild Mouse"),
("SPINNING", "Spinning"),
("FOURTH_DIMENSION", "4th Dimension"),
("OTHER", "Other"),
],
default="SITDOWN",
max_length=20,
),
),
migrations.AlterField(
model_name="rollercoasterstats",
name="track_material",
field=models.CharField(
blank=True,
choices=[("STEEL", "Steel"), ("WOOD", "Wood"), ("HYBRID", "Hybrid")],
default="STEEL",
max_length=20,
),
),
migrations.CreateModel(
name="HistoricalRideModel",
fields=[
("id", models.BigIntegerField(blank=True, db_index=True)),
("name", models.CharField(max_length=255)),
("description", models.TextField(blank=True)),
(
"typical_height_ft",
models.DecimalField(
blank=True,
decimal_places=2,
help_text="Typical height of this model in feet",
max_digits=6,
null=True,
),
),
(
"typical_speed_mph",
models.DecimalField(
blank=True,
decimal_places=2,
help_text="Typical speed of this model in mph",
max_digits=5,
null=True,
),
),
(
"typical_capacity_per_hour",
models.PositiveIntegerField(
blank=True,
help_text="Typical hourly capacity of this model",
null=True,
),
),
(
"category",
models.CharField(
blank=True,
choices=[
("", "Select ride type"),
("RC", "Roller Coaster"),
("DR", "Dark Ride"),
("FR", "Flat Ride"),
("WR", "Water Ride"),
("TR", "Transport"),
("OT", "Other"),
],
default="",
max_length=2,
),
),
("created_at", models.DateTimeField(blank=True, editable=False)),
("updated_at", models.DateTimeField(blank=True, editable=False)),
("history_id", models.AutoField(primary_key=True, serialize=False)),
("history_date", models.DateTimeField(db_index=True)),
("history_change_reason", models.CharField(max_length=100, null=True)),
(
"history_type",
models.CharField(
choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")],
max_length=1,
),
),
(
"history_user",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="+",
to=settings.AUTH_USER_MODEL,
),
),
(
"manufacturer",
models.ForeignKey(
blank=True,
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
to="companies.manufacturer",
),
),
],
options={
"verbose_name": "historical ride model",
"verbose_name_plural": "historical ride models",
"ordering": ("-history_date", "-history_id"),
"get_latest_by": ("history_date", "history_id"),
},
bases=(simple_history.models.HistoricalChanges, models.Model),
),
migrations.CreateModel(
name="RideModel",
fields=[
("id", models.BigAutoField(primary_key=True, serialize=False)),
("name", models.CharField(max_length=255)),
("description", models.TextField(blank=True)),
(
"typical_height_ft",
models.DecimalField(
blank=True,
decimal_places=2,
help_text="Typical height of this model in feet",
max_digits=6,
null=True,
),
),
(
"typical_speed_mph",
models.DecimalField(
blank=True,
decimal_places=2,
help_text="Typical speed of this model in mph",
max_digits=5,
null=True,
),
),
(
"typical_capacity_per_hour",
models.PositiveIntegerField(
blank=True,
help_text="Typical hourly capacity of this model",
null=True,
),
),
(
"category",
models.CharField(
blank=True,
choices=[
("", "Select ride type"),
("RC", "Roller Coaster"),
("DR", "Dark Ride"),
("FR", "Flat Ride"),
("WR", "Water Ride"),
("TR", "Transport"),
("OT", "Other"),
],
default="",
max_length=2,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"manufacturer",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="ride_models",
to="companies.manufacturer",
),
),
],
options={
"ordering": ["manufacturer", "name"],
"unique_together": {("manufacturer", "name")},
},
),
migrations.AddField(
model_name="historicalride",
name="ride_model",
field=models.ForeignKey(
blank=True,
db_constraint=False,
help_text="The specific model/type of this ride",
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
to="rides.ridemodel",
),
),
migrations.AddField(
model_name="ride",
name="ride_model",
field=models.ForeignKey(
blank=True,
help_text="The specific model/type of this ride",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="rides",
to="rides.ridemodel",
),
),
migrations.DeleteModel(
name="HistoricalRollerCoasterStats",
),
]

View File

@@ -0,0 +1,37 @@
# Generated by Django 5.1.3 on 2024-11-13 00:20
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("rides", "0005_alter_rollercoasterstats_id_and_more"),
]
operations = [
migrations.RemoveField(
model_name="historicalridemodel",
name="typical_capacity_per_hour",
),
migrations.RemoveField(
model_name="historicalridemodel",
name="typical_height_ft",
),
migrations.RemoveField(
model_name="historicalridemodel",
name="typical_speed_mph",
),
migrations.RemoveField(
model_name="ridemodel",
name="typical_capacity_per_hour",
),
migrations.RemoveField(
model_name="ridemodel",
name="typical_height_ft",
),
migrations.RemoveField(
model_name="ridemodel",
name="typical_speed_mph",
),
]

View File

@@ -0,0 +1,26 @@
# Generated by Django 5.1.3 on 2024-11-13 02:14
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("companies", "0002_add_designer_model"),
("rides", "0006_remove_historicalridemodel_typical_capacity_per_hour_and_more"),
]
operations = [
migrations.AlterField(
model_name="ridemodel",
name="manufacturer",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="ride_models",
to="companies.manufacturer",
),
),
]

View File

@@ -0,0 +1,75 @@
# Generated by Django 5.1.3 on 2024-11-13 04:30
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("rides", "0007_alter_ridemodel_manufacturer"),
]
operations = [
migrations.AddField(
model_name="historicalride",
name="post_closing_status",
field=models.CharField(
blank=True,
choices=[
("SBNO", "Standing But Not Operating"),
("CLOSED_PERM", "Permanently Closed"),
],
help_text="Status to change to after closing date",
max_length=20,
null=True,
),
),
migrations.AddField(
model_name="ride",
name="post_closing_status",
field=models.CharField(
blank=True,
choices=[
("SBNO", "Standing But Not Operating"),
("CLOSED_PERM", "Permanently Closed"),
],
help_text="Status to change to after closing date",
max_length=20,
null=True,
),
),
migrations.AlterField(
model_name="historicalride",
name="status",
field=models.CharField(
choices=[
("OPERATING", "Operating"),
("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="ride",
name="status",
field=models.CharField(
choices=[
("OPERATING", "Operating"),
("SBNO", "Standing But Not Operating"),
("CLOSING", "Closing"),
("CLOSED_PERM", "Permanently Closed"),
("UNDER_CONSTRUCTION", "Under Construction"),
("DEMOLISHED", "Demolished"),
("RELOCATED", "Relocated"),
],
default="OPERATING",
max_length=20,
),
),
]

View File

@@ -0,0 +1,21 @@
# Generated by Django 5.1.3 on 2024-11-13 04:44
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("rides", "0008_historicalride_post_closing_status_and_more"),
]
operations = [
migrations.RemoveField(
model_name="historicalride",
name="model_name",
),
migrations.RemoveField(
model_name="ride",
name="model_name",
),
]

View File

@@ -1,29 +1,68 @@
from typing import Tuple, Optional, Any
from django.db import models
from django.contrib.contenttypes.fields import GenericRelation
from django.utils.text import slugify
from simple_history.models import HistoricalRecords
from django.contrib.contenttypes.fields import GenericRelation
from history_tracking.models import HistoricalModel
class Ride(HistoricalModel):
CATEGORY_CHOICES = [
# Shared choices that will be used by multiple models
CATEGORY_CHOICES = [
('', 'Select ride type'),
('RC', 'Roller Coaster'),
('DR', 'Dark Ride'),
('FR', 'Flat Ride'),
('WR', 'Water Ride'),
('TR', 'Transport'),
('OT', 'Other'),
]
]
class RideModel(HistoricalModel):
"""
Represents a specific model/type of ride that can be manufactured by different companies.
For example: B&M Dive Coaster, Vekoma Boomerang, etc.
"""
name = models.CharField(max_length=255)
manufacturer = models.ForeignKey(
'companies.Manufacturer',
on_delete=models.SET_NULL, # Changed to SET_NULL since it's optional
related_name='ride_models',
null=True, # Made optional
blank=True # Made optional
)
description = models.TextField(blank=True)
category = models.CharField(
max_length=2,
choices=CATEGORY_CHOICES,
default='',
blank=True
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ['manufacturer', 'name']
unique_together = ['manufacturer', 'name']
def __str__(self) -> str:
return self.name if not self.manufacturer else f"{self.manufacturer.name} {self.name}"
class Ride(HistoricalModel):
STATUS_CHOICES = [
('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'),
]
POST_CLOSING_STATUS_CHOICES = [
('SBNO', 'Standing But Not Operating'),
('CLOSED_PERM', 'Permanently Closed'),
]
name = models.CharField(max_length=255)
slug = models.SlugField(max_length=255)
description = models.TextField(blank=True)
@@ -42,34 +81,47 @@ class Ride(HistoricalModel):
category = models.CharField(
max_length=2,
choices=CATEGORY_CHOICES,
default='OT'
default='',
blank=True
)
manufacturer = models.ForeignKey(
'companies.manufacturer',
'companies.Manufacturer',
on_delete=models.CASCADE,
null=False,
blank=False
null=True,
blank=True
)
designer = models.ForeignKey(
'designers.Designer',
'companies.Designer',
on_delete=models.SET_NULL,
related_name='rides',
null=True,
blank=True
)
ride_model = models.ForeignKey(
'RideModel',
on_delete=models.SET_NULL,
related_name='rides',
null=True,
blank=True,
help_text='The designer/engineering firm responsible for the ride'
help_text="The specific model/type of this ride"
)
model_name = models.CharField(max_length=255, blank=True)
status = models.CharField(
max_length=20,
choices=STATUS_CHOICES,
default='OPERATING'
)
post_closing_status = models.CharField(
max_length=20,
choices=POST_CLOSING_STATUS_CHOICES,
null=True,
blank=True,
help_text="Status to change to after closing date"
)
opening_date = models.DateField(null=True, blank=True)
closing_date = models.DateField(null=True, blank=True)
status_since = models.DateField(null=True, blank=True)
min_height_in = models.PositiveIntegerField(null=True, blank=True)
max_height_in = models.PositiveIntegerField(null=True, blank=True)
accessibility_options = models.TextField(blank=True)
capacity_per_hour = models.PositiveIntegerField(null=True, blank=True)
ride_duration_seconds = models.PositiveIntegerField(null=True, blank=True)
average_rating = models.DecimalField(
@@ -90,64 +142,25 @@ class Ride(HistoricalModel):
def __str__(self) -> str:
return f"{self.name} at {self.park.name}"
def save(self, *args: Any, **kwargs: Any) -> None:
def save(self, *args, **kwargs) -> None:
if not self.slug:
self.slug = slugify(self.name)
super().save(*args, **kwargs)
@classmethod
def get_by_slug(cls, slug: str) -> Tuple['Ride', bool]:
"""Get ride by current or historical slug.
Args:
slug: The slug to look up
Returns:
A tuple of (Ride object, bool indicating if it's a historical slug)
Raises:
cls.DoesNotExist: If no ride is found with the given slug
"""
try:
return cls.objects.get(slug=slug), False
except cls.DoesNotExist as e:
# Check historical slugs
if history := cls.history.filter(slug=slug).order_by('-history_date').first(): # type: ignore[attr-defined]
try:
return cls.objects.get(pk=history.instance.pk), True
except cls.DoesNotExist as inner_e:
raise cls.DoesNotExist("No ride found with this slug") from inner_e
raise cls.DoesNotExist("No ride found with this slug") from e
class RollerCoasterStats(HistoricalModel):
LAUNCH_CHOICES = [
('CHAIN', 'Chain Lift'),
('CABLE', 'Cable Launch'),
('HYDRAULIC', 'Hydraulic Launch'),
('LSM', 'Linear Synchronous Motor'),
('LIM', 'Linear Induction Motor'),
('GRAVITY', 'Gravity'),
('OTHER', 'Other'),
]
class RollerCoasterStats(models.Model):
TRACK_MATERIAL_CHOICES = [
('STEEL', 'Steel'),
('WOOD', 'Wood'),
('HYBRID', 'Hybrid'),
('OTHER', 'Other'),
]
COASTER_TYPE_CHOICES = [
('SITDOWN', 'Sit-Down'),
('SITDOWN', 'Sit Down'),
('INVERTED', 'Inverted'),
('FLYING', 'Flying'),
('STANDUP', 'Stand-Up'),
('STANDUP', 'Stand Up'),
('WING', 'Wing'),
('SUSPENDED', 'Suspended'),
('BOBSLED', 'Bobsled'),
('PIPELINE', 'Pipeline'),
('MOTORBIKE', 'Motorbike'),
('FLOORLESS', 'Floorless'),
('DIVE', 'Dive'),
('FAMILY', 'Family'),
('WILD_MOUSE', 'Wild Mouse'),
@@ -156,6 +169,14 @@ class RollerCoasterStats(HistoricalModel):
('OTHER', 'Other'),
]
LAUNCH_CHOICES = [
('CHAIN', 'Chain Lift'),
('LSM', 'LSM Launch'),
('HYDRAULIC', 'Hydraulic Launch'),
('GRAVITY', 'Gravity'),
('OTHER', 'Other'),
]
ride = models.OneToOneField(
Ride,
on_delete=models.CASCADE,
@@ -192,15 +213,13 @@ class RollerCoasterStats(HistoricalModel):
max_length=20,
choices=COASTER_TYPE_CHOICES,
default='SITDOWN',
blank=True,
help_text='The type/style of roller coaster (e.g. Sit-Down, Inverted, Flying)'
blank=True
)
max_drop_height_ft = models.DecimalField(
max_digits=6,
decimal_places=2,
null=True,
blank=True,
help_text='Maximum vertical drop height in feet'
blank=True
)
launch_type = models.CharField(
max_length=20,

View File

@@ -0,0 +1,17 @@
from django.db.models.signals import pre_save
from django.dispatch import receiver
from django.utils import timezone
from .models import Ride
@receiver(pre_save, sender=Ride)
def handle_ride_status(sender, instance, **kwargs):
"""Handle ride status changes based on closing date"""
if instance.closing_date:
today = timezone.now().date()
# If we've reached the closing date and status is "Closing"
if today >= instance.closing_date and instance.status == 'CLOSING':
# Change to the selected post-closing status
instance.status = instance.post_closing_status or 'SBNO'
instance.status_since = instance.closing_date

View File

@@ -1,21 +1,62 @@
from django.urls import path
from . import views
app_name = 'rides' # Add namespace
app_name = "rides"
urlpatterns = [
# Global category URLs
path('', views.RideListView.as_view(), name='ride_list'),
path('all/', views.RideListView.as_view(), name='all_rides'),
path('roller_coasters/', views.SingleCategoryListView.as_view(), {'category': 'RC'}, name='roller_coasters'),
path('dark_rides/', views.SingleCategoryListView.as_view(), {'category': 'DR'}, name='dark_rides'),
path('flat_rides/', views.SingleCategoryListView.as_view(), {'category': 'FR'}, name='flat_rides'),
path('water_rides/', views.SingleCategoryListView.as_view(), {'category': 'WR'}, name='water_rides'),
path('transports/', views.SingleCategoryListView.as_view(), {'category': 'TR'}, name='transports'),
path('others/', views.SingleCategoryListView.as_view(), {'category': 'OT'}, name='others'),
# List views
path("", views.RideListView.as_view(), name="ride_list"),
path("create/", views.RideCreateView.as_view(), name="ride_create"),
# Basic ride URLs
path('create/', views.RideCreateView.as_view(), name='ride_create'),
path('<slug:ride_slug>/edit/', views.RideUpdateView.as_view(), name='ride_edit'),
path('<slug:ride_slug>/', views.RideDetailView.as_view(), name='ride_detail'),
# Search endpoints
path(
"search/manufacturers/", views.search_manufacturers, name="search_manufacturers"
),
path("search/designers/", views.search_designers, name="search_designers"),
path("search/models/", views.search_ride_models, name="search_ride_models"),
# HTMX endpoints
path("coaster-fields/", views.show_coaster_fields, name="coaster_fields"),
# Category views for global listing
path(
"roller_coasters/",
views.SingleCategoryListView.as_view(),
{"category": "RC"},
name="roller_coasters",
),
path(
"dark_rides/",
views.SingleCategoryListView.as_view(),
{"category": "DR"},
name="dark_rides",
),
path(
"flat_rides/",
views.SingleCategoryListView.as_view(),
{"category": "FR"},
name="flat_rides",
),
path(
"water_rides/",
views.SingleCategoryListView.as_view(),
{"category": "WR"},
name="water_rides",
),
path(
"transports/",
views.SingleCategoryListView.as_view(),
{"category": "TR"},
name="transports",
),
path(
"others/",
views.SingleCategoryListView.as_view(),
{"category": "OT"},
name="others",
),
# Detail and update views - must come after category views
path("<slug:ride_slug>/", views.RideDetailView.as_view(), name="ride_detail"),
path("<slug:ride_slug>/update/", views.RideUpdateView.as_view(), name="ride_update"),
]

View File

@@ -1,10 +1,11 @@
from typing import Any, Dict, Optional, Tuple, Union, cast
from django.views.generic import DetailView, ListView, CreateView, UpdateView
from django.shortcuts import get_object_or_404
from typing import Any, Dict, Optional, Tuple, Union, cast, Type
from django.views.generic import DetailView, ListView, CreateView, UpdateView, RedirectView
from django.shortcuts import get_object_or_404, render
from django.core.serializers.json import DjangoJSONEncoder
from django.urls import reverse
from django.db.models import Q
from django.db.models import Q, Model
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.auth.decorators import login_required
from django.contrib.contenttypes.models import ContentType
from django.contrib import messages
from django.http import (
@@ -17,7 +18,11 @@ from django.http import (
from django.db.models import Count
from django.core.files.uploadedfile import UploadedFile
from django.forms import ModelForm
from .models import Ride, RollerCoasterStats
from django.db.models.query import QuerySet
from simple_history.models import HistoricalRecords
from django.contrib.auth import get_user_model
from django.contrib.auth.models import AnonymousUser
from .models import Ride, RollerCoasterStats, RideModel, CATEGORY_CHOICES
from .forms import RideForm
from parks.models import Park
from core.views import SlugRedirectMixin
@@ -25,481 +30,328 @@ from moderation.mixins import EditSubmissionMixin, PhotoSubmissionMixin, History
from moderation.models import EditSubmission
from media.models import Photo
from accounts.models import User
from companies.models import Manufacturer, Designer
def is_privileged_user(user: Any) -> bool:
"""Check if the user has privileged access.
Args:
user: The user to check
Returns:
bool: True if user has privileged or higher privileges
"""
return isinstance(user, User) and user.role in ["MODERATOR", "ADMIN", "SUPERUSER"]
def handle_photo_uploads(request: HttpRequest, ride: Ride) -> int:
"""Handle photo uploads for a ride.
Args:
request: The HTTP request containing files
ride: The ride to attach photos to
Returns:
int: Number of successfully uploaded photos
"""
uploaded_count = 0
photos = request.FILES.getlist("photos")
for photo_file in photos:
try:
Photo.objects.create(
image=photo_file,
uploaded_by=request.user,
content_type=ContentType.objects.get_for_model(Ride),
object_id=ride.pk,
)
uploaded_count += 1
except Exception as e:
messages.error(request, f"Error uploading photo {photo_file.name}: {str(e)}")
return uploaded_count
def prepare_form_data(cleaned_data: Dict[str, Any], park: Park) -> Dict[str, Any]:
"""Prepare form data for submission.
Args:
cleaned_data: The form's cleaned data
park: The park instance
Returns:
Dict[str, Any]: Processed form data ready for submission
"""
data = cleaned_data.copy()
data["park"] = park.pk
if data.get("park_area"):
data["park_area"] = data["park_area"].pk
if data.get("manufacturer"):
data["manufacturer"] = data["manufacturer"].pk
return data
def handle_form_errors(request: HttpRequest, form: ModelForm) -> None:
"""Handle form validation errors by adding appropriate error messages.
Args:
request: The HTTP request
form: The form containing validation errors
"""
messages.error(
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(request, f"{field}: {error}")
def create_edit_submission(
request: HttpRequest,
submission_type: str,
changes: Dict[str, Any],
object_id: Optional[int] = None,
) -> EditSubmission:
"""Create an EditSubmission object for ride changes.
Args:
request: The HTTP request
submission_type: Type of submission (CREATE or EDIT)
changes: The changes to be submitted
object_id: Optional ID of the existing object for edits
Returns:
EditSubmission: The created submission object
"""
submission_data = {
"user": request.user,
"content_type": ContentType.objects.get_for_model(Ride),
"submission_type": submission_type,
"changes": changes,
"reason": request.POST.get("reason", ""),
"source": request.POST.get("source", ""),
}
if object_id is not None:
submission_data["object_id"] = object_id
return EditSubmission.objects.create(**submission_data)
def handle_privileged_save(
request: HttpRequest, form: RideForm, submission: EditSubmission
) -> Tuple[bool, str]:
"""Handle saving form and updating submission for privileged users.
Args:
request: The HTTP request
form: The form to save
submission: The edit submission to update
Returns:
Tuple[bool, str]: Success status and error message (empty string if successful)
"""
try:
ride = form.save()
if submission.submission_type == "CREATE":
submission.object_id = ride.pk
submission.status = "APPROVED"
submission.handled_by = request.user
submission.save()
return True, ""
except Exception as e:
error_msg = (
f"Error {submission.submission_type.lower()}ing ride: {str(e)}. "
"Please check your input and try again."
)
return False, error_msg
class SingleCategoryListView(ListView):
model = Ride
template_name = "rides/ride_category_list.html"
context_object_name = "categories"
def get_category_code(self) -> str:
if category := self.kwargs.get("category"):
return category
raise Http404("Category not found")
def get_queryset(self):
category_code = self.get_category_code()
category_name = dict(Ride.CATEGORY_CHOICES)[category_code]
rides = (
Ride.objects.filter(category=category_code)
.select_related("park", "manufacturer")
.order_by("name")
)
return {category_name: rides} if rides.exists() else {}
def get_context_data(self, **kwargs) -> Dict[str, Any]:
context = super().get_context_data(**kwargs)
category_code = self.get_category_code()
category_name = dict(Ride.CATEGORY_CHOICES)[category_code]
context["title"] = f"All {category_name}s"
context["category_code"] = category_code
return context
class ParkSingleCategoryListView(ListView):
model = Ride
template_name = "rides/ride_category_list.html"
context_object_name = "categories"
def setup(self, request: HttpRequest, *args: Any, **kwargs: Any) -> None:
super().setup(request, *args, **kwargs)
self.park = get_object_or_404(Park, slug=self.kwargs["park_slug"])
def get_category_code(self) -> str:
if category := self.kwargs.get("category"):
return category
raise Http404("Category not found")
def get_queryset(self):
category_code = self.get_category_code()
category_name = dict(Ride.CATEGORY_CHOICES)[category_code]
rides = (
Ride.objects.filter(park=self.park, category=category_code)
.select_related("manufacturer")
.order_by("name")
)
return {category_name: rides} if rides.exists() else {}
def get_context_data(self, **kwargs) -> Dict[str, Any]:
context = super().get_context_data(**kwargs)
context["park"] = self.park
category_code = self.get_category_code()
category_name = dict(Ride.CATEGORY_CHOICES)[category_code]
context["title"] = f"{category_name}s at {self.park.name}"
context["category_code"] = category_code
return context
def show_coaster_fields(request: HttpRequest) -> HttpResponse:
"""Show roller coaster specific fields based on category selection"""
category = request.GET.get('category')
if category != 'RC': # Only show for roller coasters
return HttpResponse('')
return render(request, "rides/partials/coaster_fields.html")
class RideCreateView(LoginRequiredMixin, CreateView):
"""View for creating a new ride"""
model = Ride
form_class = RideForm
template_name = "rides/ride_form.html"
template_name = 'rides/ride_form.html'
def setup(self, request: HttpRequest, *args: Any, **kwargs: Any) -> None:
super().setup(request, *args, **kwargs)
self.park = get_object_or_404(Park, slug=self.kwargs["park_slug"])
def get_success_url(self):
"""Get URL to redirect to after successful creation"""
if hasattr(self, 'park'):
return reverse('parks:rides:ride_detail', kwargs={
'park_slug': self.park.slug,
'ride_slug': self.object.slug
})
return reverse('rides:ride_detail', kwargs={'ride_slug': self.object.slug})
def get_form_kwargs(self) -> Dict[str, Any]:
def get_form_kwargs(self):
"""Pass park to the form"""
kwargs = super().get_form_kwargs()
kwargs["park"] = self.park
if 'park_slug' in self.kwargs:
self.park = get_object_or_404(Park, slug=self.kwargs['park_slug'])
kwargs['park'] = self.park
return kwargs
def handle_submission(
self, form: RideForm, cleaned_data: Dict[str, Any]
) -> HttpResponseRedirect:
"""Handle the form submission.
Args:
form: The form to process
cleaned_data: The cleaned form data
Returns:
HttpResponseRedirect to appropriate URL
"""
submission = create_edit_submission(self.request, "CREATE", cleaned_data)
if is_privileged_user(self.request.user):
success, error_msg = handle_privileged_save(self.request, form, submission)
if success:
self.object = form.instance
uploaded_count = handle_photo_uploads(self.request, self.object)
messages.success(
self.request,
f"Successfully created {self.object.name} at {self.park.name}. "
f"Added {uploaded_count} photo(s).",
)
return HttpResponseRedirect(self.get_success_url())
else:
if error_msg: # Only add error message if there is one
messages.error(self.request, error_msg)
return cast(HttpResponseRedirect, self.form_invalid(form))
messages.success(
self.request,
"Your ride submission has been sent for review. "
"You will be notified when it is approved.",
)
return HttpResponseRedirect(
reverse("parks:rides:ride_list", kwargs={"park_slug": self.park.slug})
)
def form_valid(self, form: RideForm) -> HttpResponseRedirect:
form.instance.park = self.park
cleaned_data = prepare_form_data(form.cleaned_data, self.park)
return self.handle_submission(form, cleaned_data)
def form_invalid(self, form: RideForm) -> Union[HttpResponse, HttpResponseRedirect]:
"""Handle invalid form submission.
Args:
form: The invalid form
Returns:
Response with error messages
"""
handle_form_errors(self.request, form)
return super().form_invalid(form)
def get_success_url(self) -> str:
return reverse(
"parks:rides:ride_detail",
kwargs={"park_slug": self.park.slug, "ride_slug": self.object.slug},
)
def get_context_data(self, **kwargs) -> Dict[str, Any]:
def get_context_data(self, **kwargs):
"""Add park and park_slug to context"""
context = super().get_context_data(**kwargs)
context["park"] = self.park
if hasattr(self, 'park'):
context['park'] = self.park
context['park_slug'] = self.park.slug
context['is_edit'] = False
return context
def form_valid(self, form):
"""Handle form submission including new items"""
# Check for new manufacturer
manufacturer_name = form.cleaned_data.get('manufacturer_search')
if manufacturer_name and not form.cleaned_data.get('manufacturer'):
# Create submission for new manufacturer
EditSubmission.objects.create(
user=self.request.user,
content_type=ContentType.objects.get_for_model(Manufacturer),
submission_type="CREATE",
changes={"name": manufacturer_name},
)
class RideUpdateView(LoginRequiredMixin, UpdateView):
# Check for new designer
designer_name = form.cleaned_data.get('designer_search')
if designer_name and not form.cleaned_data.get('designer'):
# Create submission for new designer
EditSubmission.objects.create(
user=self.request.user,
content_type=ContentType.objects.get_for_model(Designer),
submission_type="CREATE",
changes={"name": designer_name},
)
# Check for new ride model
ride_model_name = form.cleaned_data.get('ride_model_search')
manufacturer = form.cleaned_data.get('manufacturer')
if ride_model_name and not form.cleaned_data.get('ride_model') and manufacturer:
# Create submission for new ride model
EditSubmission.objects.create(
user=self.request.user,
content_type=ContentType.objects.get_for_model(RideModel),
submission_type="CREATE",
changes={
"name": ride_model_name,
"manufacturer": manufacturer.id
},
)
return super().form_valid(form)
class RideDetailView(DetailView):
"""View for displaying ride details"""
model = Ride
form_class = RideForm
template_name = "rides/ride_form.html"
slug_url_kwarg = "ride_slug"
def setup(self, request: HttpRequest, *args: Any, **kwargs: Any) -> None:
super().setup(request, *args, **kwargs)
self.park = get_object_or_404(Park, slug=self.kwargs["park_slug"])
def get_form_kwargs(self) -> Dict[str, Any]:
kwargs = super().get_form_kwargs()
kwargs["park"] = self.park
return kwargs
def get_context_data(self, **kwargs) -> Dict[str, Any]:
context = super().get_context_data(**kwargs)
context["park"] = self.park
context["is_edit"] = True
return context
def handle_submission(
self, form: RideForm, cleaned_data: Dict[str, Any]
) -> HttpResponseRedirect:
"""Handle the form submission.
Args:
form: The form to process
cleaned_data: The cleaned form data
Returns:
HttpResponseRedirect to appropriate URL
"""
submission = create_edit_submission(
self.request, "EDIT", cleaned_data, self.object.pk
)
if is_privileged_user(self.request.user):
success, error_msg = handle_privileged_save(self.request, form, submission)
if success:
self.object = form.instance
uploaded_count = handle_photo_uploads(self.request, self.object)
messages.success(
self.request,
f"Successfully updated {self.object.name}. "
f"Added {uploaded_count} new photo(s).",
)
return HttpResponseRedirect(self.get_success_url())
else:
if error_msg: # Only add error message if there is one
messages.error(self.request, error_msg)
return cast(HttpResponseRedirect, 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:rides:ride_detail",
kwargs={"park_slug": self.park.slug, "ride_slug": self.object.slug},
)
)
def form_valid(self, form: RideForm) -> HttpResponseRedirect:
cleaned_data = prepare_form_data(form.cleaned_data, self.park)
return self.handle_submission(form, cleaned_data)
def form_invalid(self, form: RideForm) -> Union[HttpResponse, HttpResponseRedirect]:
"""Handle invalid form submission.
Args:
form: The invalid form
Returns:
Response with error messages
"""
handle_form_errors(self.request, form)
return super().form_invalid(form)
def get_success_url(self) -> str:
return reverse(
"parks:rides:ride_detail",
kwargs={"park_slug": self.park.slug, "ride_slug": self.object.slug},
)
class RideDetailView(
SlugRedirectMixin,
EditSubmissionMixin,
PhotoSubmissionMixin,
HistoryMixin,
DetailView,
):
model = Ride
template_name = "rides/ride_detail.html"
context_object_name = "ride"
slug_url_kwarg = "ride_slug"
def get_object(self, queryset=None):
if queryset is None:
queryset = self.get_queryset()
park_slug = self.kwargs.get("park_slug")
ride_slug = self.kwargs.get("ride_slug")
obj, is_old_slug = self.model.get_by_slug(ride_slug) # type: ignore[attr-defined]
if obj.park.slug != park_slug:
raise self.model.DoesNotExist("Park slug doesn't match")
return obj
def get_context_data(self, **kwargs) -> Dict[str, Any]:
context = super().get_context_data(**kwargs)
if self.object.category == "RC":
context["coaster_stats"] = RollerCoasterStats.objects.filter(
ride=self.object
).first()
return context
def get_redirect_url_pattern(self) -> str:
return "parks:rides:ride_detail"
def get_redirect_url_kwargs(self) -> Dict[str, Any]:
return {"park_slug": self.object.park.slug, "ride_slug": self.object.slug}
class RideListView(ListView):
model = Ride
template_name = "rides/ride_list.html"
context_object_name = "rides"
def setup(self, request: HttpRequest, *args: Any, **kwargs: Any) -> None:
super().setup(request, *args, **kwargs)
self.park = None
if "park_slug" in self.kwargs:
self.park = get_object_or_404(Park, slug=self.kwargs["park_slug"])
template_name = 'rides/ride_detail.html'
slug_url_kwarg = 'ride_slug'
def get_queryset(self):
queryset = Ride.objects.select_related(
"park", "coaster_stats", "manufacturer"
).prefetch_related("photos")
"""Get ride for the specific park if park_slug is provided"""
queryset = Ride.objects.all().select_related(
'park',
'ride_model',
'ride_model__manufacturer'
).prefetch_related('photos')
if self.park:
queryset = queryset.filter(park=self.park)
search = self.request.GET.get("search", "").strip() or None
category = self.request.GET.get("category", "").strip() or None
status = self.request.GET.get("status", "").strip() or None
manufacturer = self.request.GET.get("manufacturer", "").strip() or None
if search:
if self.park:
queryset = queryset.filter(name__icontains=search)
else:
queryset = queryset.filter(
Q(name__icontains=search) | Q(park__name__icontains=search)
)
if category:
queryset = queryset.filter(category=category)
if status:
queryset = queryset.filter(status=status)
if manufacturer:
queryset = queryset.exclude(manufacturer__isnull=True)
if 'park_slug' in self.kwargs:
queryset = queryset.filter(park__slug=self.kwargs['park_slug'])
return queryset
def get_context_data(self, **kwargs) -> Dict[str, Any]:
def get_context_data(self, **kwargs):
"""Add park_slug to context if it exists"""
context = super().get_context_data(**kwargs)
context["park"] = self.park
manufacturer_query = Ride.objects
if self.park:
manufacturer_query = manufacturer_query.filter(park=self.park)
context["manufacturers"] = list(
manufacturer_query.exclude(manufacturer__isnull=True)
.values_list("manufacturer__name", flat=True)
.distinct()
.order_by("manufacturer__name")
)
context["current_filters"] = {
"search": self.request.GET.get("search", ""),
"category": self.request.GET.get("category", ""),
"status": self.request.GET.get("status", ""),
"manufacturer": self.request.GET.get("manufacturer", ""),
}
if 'park_slug' in self.kwargs:
context['park_slug'] = self.kwargs['park_slug']
return context
def get(self, request: HttpRequest, *args: Any, **kwargs: Any):
if getattr(request, "htmx", False): # type: ignore[attr-defined]
self.template_name = "rides/partials/ride_list.html"
return super().get(request, *args, **kwargs)
class RideUpdateView(LoginRequiredMixin, EditSubmissionMixin, UpdateView):
"""View for updating an existing ride"""
model = Ride
form_class = RideForm
template_name = 'rides/ride_form.html'
slug_url_kwarg = 'ride_slug'
def get_success_url(self):
"""Get URL to redirect to after successful update"""
if hasattr(self, 'park'):
return reverse('parks:rides:ride_detail', kwargs={
'park_slug': self.park.slug,
'ride_slug': self.object.slug
})
return reverse('rides:ride_detail', kwargs={'ride_slug': self.object.slug})
def get_queryset(self):
"""Get ride for the specific park if park_slug is provided"""
queryset = Ride.objects.all()
if 'park_slug' in self.kwargs:
queryset = queryset.filter(park__slug=self.kwargs['park_slug'])
return queryset
def get_form_kwargs(self):
"""Pass park to the form"""
kwargs = super().get_form_kwargs()
# For park-specific URLs, use the park from the URL
if 'park_slug' in self.kwargs:
self.park = get_object_or_404(Park, slug=self.kwargs['park_slug'])
kwargs['park'] = self.park
# For global URLs, use the ride's park
else:
self.park = self.get_object().park
kwargs['park'] = self.park
return kwargs
def get_context_data(self, **kwargs):
"""Add park and park_slug to context"""
context = super().get_context_data(**kwargs)
if hasattr(self, 'park'):
context['park'] = self.park
context['park_slug'] = self.park.slug
context['is_edit'] = True
return context
def form_valid(self, form):
"""Handle form submission including new items"""
# Check for new manufacturer
manufacturer_name = form.cleaned_data.get('manufacturer_search')
if manufacturer_name and not form.cleaned_data.get('manufacturer'):
# Create submission for new manufacturer
EditSubmission.objects.create(
user=self.request.user,
content_type=ContentType.objects.get_for_model(Manufacturer),
submission_type="CREATE",
changes={"name": manufacturer_name},
)
# Check for new designer
designer_name = form.cleaned_data.get('designer_search')
if designer_name and not form.cleaned_data.get('designer'):
# Create submission for new designer
EditSubmission.objects.create(
user=self.request.user,
content_type=ContentType.objects.get_for_model(Designer),
submission_type="CREATE",
changes={"name": designer_name},
)
# Check for new ride model
ride_model_name = form.cleaned_data.get('ride_model_search')
manufacturer = form.cleaned_data.get('manufacturer')
if ride_model_name and not form.cleaned_data.get('ride_model') and manufacturer:
# Create submission for new ride model
EditSubmission.objects.create(
user=self.request.user,
content_type=ContentType.objects.get_for_model(RideModel),
submission_type="CREATE",
changes={
"name": ride_model_name,
"manufacturer": manufacturer.id
},
)
return super().form_valid(form)
class RideListView(ListView):
"""View for displaying a list of rides"""
model = Ride
template_name = 'rides/ride_list.html'
context_object_name = 'rides'
def get_queryset(self):
"""Get all rides or filter by park if park_slug is provided"""
queryset = Ride.objects.all().select_related(
'park',
'ride_model',
'ride_model__manufacturer'
).prefetch_related('photos')
if 'park_slug' in self.kwargs:
self.park = get_object_or_404(Park, slug=self.kwargs['park_slug'])
queryset = queryset.filter(park=self.park)
return queryset
def get_context_data(self, **kwargs):
"""Add park to context if park_slug is provided"""
context = super().get_context_data(**kwargs)
if hasattr(self, 'park'):
context['park'] = self.park
context['park_slug'] = self.kwargs['park_slug']
return context
class SingleCategoryListView(ListView):
"""View for displaying rides of a specific category"""
model = Ride
template_name = 'rides/park_category_list.html'
context_object_name = 'rides'
def get_queryset(self):
"""Get rides filtered by category and optionally by park"""
category = self.kwargs.get('category')
queryset = Ride.objects.filter(
category=category
).select_related(
'park',
'ride_model',
'ride_model__manufacturer'
)
if 'park_slug' in self.kwargs:
self.park = get_object_or_404(Park, slug=self.kwargs['park_slug'])
queryset = queryset.filter(park=self.park)
return queryset
def get_context_data(self, **kwargs):
"""Add park and category information to context"""
context = super().get_context_data(**kwargs)
if hasattr(self, 'park'):
context['park'] = self.park
context['park_slug'] = self.kwargs['park_slug']
context['category'] = dict(CATEGORY_CHOICES).get(self.kwargs['category'])
return context
# Alias for parks app to maintain backward compatibility
ParkSingleCategoryListView = SingleCategoryListView
def is_privileged_user(user: Any) -> bool:
"""Check if user has privileged access"""
return bool(user and hasattr(user, 'is_staff') and (user.is_staff or user.is_superuser))
@login_required
def search_manufacturers(request: HttpRequest) -> HttpResponse:
"""Search manufacturers and return results for HTMX"""
query = request.GET.get("q", "").strip()
# Show all manufacturers on click, filter on input
manufacturers = Manufacturer.objects.all().order_by("name")
if query:
manufacturers = manufacturers.filter(name__icontains=query)
manufacturers = manufacturers[:10]
return render(
request,
"rides/partials/manufacturer_search_results.html",
{"manufacturers": manufacturers, "search_term": query},
)
@login_required
def search_designers(request: HttpRequest) -> HttpResponse:
"""Search designers and return results for HTMX"""
query = request.GET.get("q", "").strip()
# Show all designers on click, filter on input
designers = Designer.objects.all().order_by("name")
if query:
designers = designers.filter(name__icontains=query)
designers = designers[:10]
return render(
request,
"rides/partials/designer_search_results.html",
{"designers": designers, "search_term": query},
)
@login_required
def search_ride_models(request: HttpRequest) -> HttpResponse:
"""Search ride models and return results for HTMX"""
query = request.GET.get("q", "").strip()
manufacturer_id = request.GET.get("manufacturer")
# Show all ride models on click, filter on input
ride_models = RideModel.objects.select_related("manufacturer").order_by("name")
if query:
ride_models = ride_models.filter(name__icontains=query)
if manufacturer_id:
ride_models = ride_models.filter(manufacturer_id=manufacturer_id)
ride_models = ride_models[:10]
return render(
request,
"rides/partials/ride_model_search_results.html",
{"ride_models": ride_models, "search_term": query, "manufacturer_id": manufacturer_id},
)

View File

@@ -749,35 +749,59 @@ select {
--tw-contain-style: ;
}
.\!container {
width: 100% !important;
}
.container {
width: 100%;
}
@media (min-width: 640px) {
.\!container {
max-width: 640px !important;
}
.container {
max-width: 640px;
}
}
@media (min-width: 768px) {
.\!container {
max-width: 768px !important;
}
.container {
max-width: 768px;
}
}
@media (min-width: 1024px) {
.\!container {
max-width: 1024px !important;
}
.container {
max-width: 1024px;
}
}
@media (min-width: 1280px) {
.\!container {
max-width: 1280px !important;
}
.container {
max-width: 1280px;
}
}
@media (min-width: 1536px) {
.\!container {
max-width: 1536px !important;
}
.container {
max-width: 1536px;
}
@@ -2157,6 +2181,10 @@ select {
justify-content: center;
}
.visible {
visibility: visible;
}
.static {
position: static;
}
@@ -2245,6 +2273,16 @@ select {
grid-column: 1 / -1;
}
.-mx-1\.5 {
margin-left: -0.375rem;
margin-right: -0.375rem;
}
.-my-1\.5 {
margin-top: -0.375rem;
margin-bottom: -0.375rem;
}
.mx-1 {
margin-left: 0.25rem;
margin-right: 0.25rem;
@@ -2270,6 +2308,11 @@ select {
margin-right: auto;
}
.my-auto {
margin-top: auto;
margin-bottom: auto;
}
.-mb-px {
margin-bottom: -1px;
}
@@ -2314,22 +2357,42 @@ select {
margin-left: 0.25rem;
}
.ml-1\.5 {
margin-left: 0.375rem;
}
.ml-2 {
margin-left: 0.5rem;
}
.ml-3 {
margin-left: 0.75rem;
}
.ml-6 {
margin-left: 1.5rem;
}
.ml-auto {
margin-left: auto;
}
.mr-1 {
margin-right: 0.25rem;
}
.mr-1\.5 {
margin-right: 0.375rem;
}
.mr-2 {
margin-right: 0.5rem;
}
.mr-2\.5 {
margin-right: 0.625rem;
}
.mr-3 {
margin-right: 0.75rem;
}
@@ -2342,6 +2405,10 @@ select {
margin-top: 0.25rem;
}
.mt-1\.5 {
margin-top: 0.375rem;
}
.mt-2 {
margin-top: 0.5rem;
}
@@ -2442,10 +2509,6 @@ select {
max-height: 90vh;
}
.min-h-\[calc\(100vh-16rem\)\] {
min-height: calc(100vh - 16rem);
}
.min-h-screen {
min-height: 100vh;
}
@@ -2491,6 +2554,10 @@ select {
width: 100%;
}
.min-w-\[200px\] {
min-width: 200px;
}
.max-w-2xl {
max-width: 42rem;
}
@@ -2503,10 +2570,18 @@ select {
max-width: 56rem;
}
.max-w-6xl {
max-width: 72rem;
}
.max-w-7xl {
max-width: 80rem;
}
.max-w-\[800px\] {
max-width: 800px;
}
.max-w-lg {
max-width: 32rem;
}
@@ -2519,10 +2594,18 @@ select {
max-width: none;
}
.max-w-xs {
max-width: 20rem;
}
.flex-1 {
flex: 1 1 0%;
}
.flex-shrink-0 {
flex-shrink: 0;
}
.flex-grow {
flex-grow: 1;
}
@@ -2542,6 +2625,11 @@ select {
transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
}
.translate-y-full {
--tw-translate-y: 100%;
transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
}
.scale-100 {
--tw-scale-x: 1;
--tw-scale-y: 1;
@@ -2572,6 +2660,10 @@ select {
cursor: pointer;
}
.resize-none {
resize: none;
}
.grid-cols-1 {
grid-template-columns: repeat(1, minmax(0, 1fr));
}
@@ -2632,6 +2724,10 @@ select {
gap: 0.5rem;
}
.gap-3 {
gap: 0.75rem;
}
.gap-4 {
gap: 1rem;
}
@@ -2652,6 +2748,12 @@ select {
margin-left: calc(0.5rem * calc(1 - var(--tw-space-x-reverse)));
}
.space-x-3 > :not([hidden]) ~ :not([hidden]) {
--tw-space-x-reverse: 0;
margin-right: calc(0.75rem * var(--tw-space-x-reverse));
margin-left: calc(0.75rem * calc(1 - var(--tw-space-x-reverse)));
}
.space-x-4 > :not([hidden]) ~ :not([hidden]) {
--tw-space-x-reverse: 0;
margin-right: calc(1rem * var(--tw-space-x-reverse));
@@ -2696,6 +2798,10 @@ select {
overflow: hidden;
}
.overflow-y-auto {
overflow-y: auto;
}
.rounded {
border-radius: 0.25rem;
}
@@ -2770,6 +2876,10 @@ select {
border-style: dashed;
}
.border-blue-200\/50 {
border-color: rgb(191 219 254 / 0.5);
}
.border-blue-500 {
--tw-border-opacity: 1;
border-color: rgb(59 130 246 / var(--tw-border-opacity));
@@ -2794,11 +2904,21 @@ select {
border-color: rgb(209 213 219 / var(--tw-border-opacity));
}
.border-gray-700 {
--tw-border-opacity: 1;
border-color: rgb(55 65 81 / var(--tw-border-opacity));
}
.border-green-500 {
--tw-border-opacity: 1;
border-color: rgb(34 197 94 / var(--tw-border-opacity));
}
.border-primary {
--tw-border-opacity: 1;
border-color: rgb(79 70 229 / var(--tw-border-opacity));
}
.border-red-400 {
--tw-border-opacity: 1;
border-color: rgb(248 113 113 / var(--tw-border-opacity));
@@ -2846,6 +2966,10 @@ select {
background-color: rgb(37 99 235 / var(--tw-bg-opacity));
}
.bg-blue-900\/40 {
background-color: rgb(30 58 138 / 0.4);
}
.bg-gray-100 {
--tw-bg-opacity: 1;
background-color: rgb(243 244 246 / var(--tw-bg-opacity));
@@ -2861,6 +2985,25 @@ select {
background-color: rgb(249 250 251 / var(--tw-bg-opacity));
}
.bg-gray-500 {
--tw-bg-opacity: 1;
background-color: rgb(107 114 128 / var(--tw-bg-opacity));
}
.bg-gray-800 {
--tw-bg-opacity: 1;
background-color: rgb(31 41 55 / var(--tw-bg-opacity));
}
.bg-gray-900 {
--tw-bg-opacity: 1;
background-color: rgb(17 24 39 / var(--tw-bg-opacity));
}
.bg-gray-900\/80 {
background-color: rgb(17 24 39 / 0.8);
}
.bg-green-100 {
--tw-bg-opacity: 1;
background-color: rgb(220 252 231 / var(--tw-bg-opacity));
@@ -2876,6 +3019,10 @@ select {
background-color: rgb(22 163 74 / var(--tw-bg-opacity));
}
.bg-green-900\/40 {
background-color: rgb(20 83 45 / 0.4);
}
.bg-red-100 {
--tw-bg-opacity: 1;
background-color: rgb(254 226 226 / var(--tw-bg-opacity));
@@ -2891,6 +3038,10 @@ select {
background-color: rgb(220 38 38 / var(--tw-bg-opacity));
}
.bg-red-900\/40 {
background-color: rgb(127 29 29 / 0.4);
}
.bg-white {
--tw-bg-opacity: 1;
background-color: rgb(255 255 255 / var(--tw-bg-opacity));
@@ -2900,6 +3051,10 @@ select {
background-color: rgb(255 255 255 / 0.1);
}
.bg-white\/80 {
background-color: rgb(255 255 255 / 0.8);
}
.bg-white\/90 {
background-color: rgb(255 255 255 / 0.9);
}
@@ -2919,10 +3074,18 @@ select {
background-color: rgb(202 138 4 / var(--tw-bg-opacity));
}
.bg-yellow-900\/40 {
background-color: rgb(113 63 18 / 0.4);
}
.bg-opacity-50 {
--tw-bg-opacity: 0.5;
}
.bg-opacity-75 {
--tw-bg-opacity: 0.75;
}
.bg-opacity-90 {
--tw-bg-opacity: 0.9;
}
@@ -2965,6 +3128,11 @@ select {
background-clip: text;
}
.object-contain {
-o-object-fit: contain;
object-fit: contain;
}
.object-cover {
-o-object-fit: cover;
object-fit: cover;
@@ -3047,6 +3215,11 @@ select {
padding-bottom: 0.5rem;
}
.py-2\.5 {
padding-top: 0.625rem;
padding-bottom: 0.625rem;
}
.py-3 {
padding-top: 0.75rem;
padding-bottom: 0.75rem;
@@ -3071,6 +3244,10 @@ select {
padding-bottom: 1rem;
}
.text-left {
text-align: left;
}
.text-center {
text-align: center;
}
@@ -3090,6 +3267,11 @@ select {
line-height: 2.5rem;
}
.text-5xl {
font-size: 3rem;
line-height: 1;
}
.text-lg {
font-size: 1.125rem;
line-height: 1.75rem;
@@ -3118,14 +3300,31 @@ select {
font-weight: 500;
}
.font-normal {
font-weight: 400;
}
.font-semibold {
font-weight: 600;
}
.uppercase {
text-transform: uppercase;
}
.lowercase {
text-transform: lowercase;
}
.leading-tight {
line-height: 1.25;
}
.text-blue-400 {
--tw-text-opacity: 1;
color: rgb(96 165 250 / var(--tw-text-opacity));
}
.text-blue-500 {
--tw-text-opacity: 1;
color: rgb(59 130 246 / var(--tw-text-opacity));
@@ -3136,16 +3335,16 @@ select {
color: rgb(37 99 235 / var(--tw-text-opacity));
}
.text-blue-700 {
--tw-text-opacity: 1;
color: rgb(29 78 216 / var(--tw-text-opacity));
}
.text-blue-800 {
--tw-text-opacity: 1;
color: rgb(30 64 175 / var(--tw-text-opacity));
}
.text-blue-900 {
--tw-text-opacity: 1;
color: rgb(30 58 138 / var(--tw-text-opacity));
}
.text-gray-200 {
--tw-text-opacity: 1;
color: rgb(229 231 235 / var(--tw-text-opacity));
@@ -3181,11 +3380,21 @@ select {
color: rgb(17 24 39 / var(--tw-text-opacity));
}
.text-green-400 {
--tw-text-opacity: 1;
color: rgb(74 222 128 / var(--tw-text-opacity));
}
.text-green-600 {
--tw-text-opacity: 1;
color: rgb(22 163 74 / var(--tw-text-opacity));
}
.text-green-700 {
--tw-text-opacity: 1;
color: rgb(21 128 61 / var(--tw-text-opacity));
}
.text-green-800 {
--tw-text-opacity: 1;
color: rgb(22 101 52 / var(--tw-text-opacity));
@@ -3196,6 +3405,11 @@ select {
color: rgb(79 70 229 / var(--tw-text-opacity));
}
.text-red-400 {
--tw-text-opacity: 1;
color: rgb(248 113 113 / var(--tw-text-opacity));
}
.text-red-500 {
--tw-text-opacity: 1;
color: rgb(239 68 68 / var(--tw-text-opacity));
@@ -3245,6 +3459,11 @@ select {
color: rgb(202 138 4 / var(--tw-text-opacity));
}
.text-yellow-700 {
--tw-text-opacity: 1;
color: rgb(161 98 7 / var(--tw-text-opacity));
}
.text-yellow-800 {
--tw-text-opacity: 1;
color: rgb(133 77 14 / var(--tw-text-opacity));
@@ -3313,6 +3532,12 @@ select {
backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);
}
.backdrop-blur-sm {
--tw-backdrop-blur: blur(4px);
-webkit-backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);
backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);
}
.transition {
transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, -webkit-backdrop-filter;
transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter;
@@ -3422,6 +3647,16 @@ select {
border-color: rgb(209 213 219 / var(--tw-border-opacity));
}
.hover\:bg-blue-100:hover {
--tw-bg-opacity: 1;
background-color: rgb(219 234 254 / var(--tw-bg-opacity));
}
.hover\:bg-blue-500:hover {
--tw-bg-opacity: 1;
background-color: rgb(59 130 246 / var(--tw-bg-opacity));
}
.hover\:bg-blue-700:hover {
--tw-bg-opacity: 1;
background-color: rgb(29 78 216 / var(--tw-bg-opacity));
@@ -3447,9 +3682,9 @@ select {
background-color: rgb(249 250 251 / var(--tw-bg-opacity));
}
.hover\:bg-green-700:hover {
.hover\:bg-green-500:hover {
--tw-bg-opacity: 1;
background-color: rgb(21 128 61 / var(--tw-bg-opacity));
background-color: rgb(34 197 94 / var(--tw-bg-opacity));
}
.hover\:bg-red-50:hover {
@@ -3457,6 +3692,11 @@ select {
background-color: rgb(254 242 242 / var(--tw-bg-opacity));
}
.hover\:bg-red-500:hover {
--tw-bg-opacity: 1;
background-color: rgb(239 68 68 / var(--tw-bg-opacity));
}
.hover\:bg-red-700:hover {
--tw-bg-opacity: 1;
background-color: rgb(185 28 28 / var(--tw-bg-opacity));
@@ -3466,6 +3706,11 @@ select {
background-color: rgb(255 255 255 / 0.2);
}
.hover\:bg-yellow-500:hover {
--tw-bg-opacity: 1;
background-color: rgb(234 179 8 / var(--tw-bg-opacity));
}
.hover\:bg-yellow-600:hover {
--tw-bg-opacity: 1;
background-color: rgb(202 138 4 / var(--tw-bg-opacity));
@@ -3486,6 +3731,16 @@ select {
color: rgb(29 78 216 / var(--tw-text-opacity));
}
.hover\:text-blue-800:hover {
--tw-text-opacity: 1;
color: rgb(30 64 175 / var(--tw-text-opacity));
}
.hover\:text-blue-900:hover {
--tw-text-opacity: 1;
color: rgb(30 58 138 / var(--tw-text-opacity));
}
.hover\:text-gray-300:hover {
--tw-text-opacity: 1;
color: rgb(209 213 219 / var(--tw-text-opacity));
@@ -3501,6 +3756,11 @@ select {
color: rgb(55 65 81 / var(--tw-text-opacity));
}
.hover\:text-gray-900:hover {
--tw-text-opacity: 1;
color: rgb(17 24 39 / var(--tw-text-opacity));
}
.hover\:text-primary:hover {
--tw-text-opacity: 1;
color: rgb(79 70 229 / var(--tw-text-opacity));
@@ -3564,6 +3824,10 @@ select {
--tw-ring-offset-width: 2px;
}
.disabled\:opacity-50:disabled {
opacity: 0.5;
}
.group:hover .group-hover\:scale-105 {
--tw-scale-x: 1.05;
--tw-scale-y: 1.05;
@@ -3574,6 +3838,10 @@ select {
opacity: 1;
}
.dark\:border-blue-700\/50:is(.dark *) {
border-color: rgb(29 78 216 / 0.5);
}
.dark\:border-gray-600:is(.dark *) {
--tw-border-opacity: 1;
border-color: rgb(75 85 99 / var(--tw-border-opacity));
@@ -3602,13 +3870,12 @@ select {
background-color: rgb(29 78 216 / var(--tw-bg-opacity));
}
.dark\:bg-blue-900:is(.dark *) {
--tw-bg-opacity: 1;
background-color: rgb(30 58 138 / var(--tw-bg-opacity));
.dark\:bg-blue-900\/30:is(.dark *) {
background-color: rgb(30 58 138 / 0.3);
}
.dark\:bg-blue-900\/50:is(.dark *) {
background-color: rgb(30 58 138 / 0.5);
.dark\:bg-blue-900\/40:is(.dark *) {
background-color: rgb(30 58 138 / 0.4);
}
.dark\:bg-gray-600:is(.dark *) {
@@ -3634,39 +3901,38 @@ select {
background-color: rgb(31 41 55 / 0.9);
}
.dark\:bg-gray-900:is(.dark *) {
--tw-bg-opacity: 1;
background-color: rgb(17 24 39 / var(--tw-bg-opacity));
}
.dark\:bg-gray-900\/80:is(.dark *) {
background-color: rgb(17 24 39 / 0.8);
}
.dark\:bg-green-200:is(.dark *) {
--tw-bg-opacity: 1;
background-color: rgb(187 247 208 / var(--tw-bg-opacity));
}
.dark\:bg-green-500:is(.dark *) {
--tw-bg-opacity: 1;
background-color: rgb(34 197 94 / var(--tw-bg-opacity));
}
.dark\:bg-green-700:is(.dark *) {
--tw-bg-opacity: 1;
background-color: rgb(21 128 61 / var(--tw-bg-opacity));
}
.dark\:bg-green-900:is(.dark *) {
--tw-bg-opacity: 1;
background-color: rgb(20 83 45 / var(--tw-bg-opacity));
}
.dark\:bg-red-200:is(.dark *) {
--tw-bg-opacity: 1;
background-color: rgb(254 202 202 / var(--tw-bg-opacity));
}
.dark\:bg-red-500:is(.dark *) {
.dark\:bg-red-700:is(.dark *) {
--tw-bg-opacity: 1;
background-color: rgb(239 68 68 / var(--tw-bg-opacity));
background-color: rgb(185 28 28 / var(--tw-bg-opacity));
}
.dark\:bg-red-900:is(.dark *) {
.dark\:bg-yellow-200:is(.dark *) {
--tw-bg-opacity: 1;
background-color: rgb(127 29 29 / var(--tw-bg-opacity));
background-color: rgb(254 240 138 / var(--tw-bg-opacity));
}
.dark\:bg-yellow-400\/30:is(.dark *) {
@@ -3678,9 +3944,13 @@ select {
background-color: rgb(202 138 4 / var(--tw-bg-opacity));
}
.dark\:bg-yellow-900:is(.dark *) {
.dark\:bg-yellow-700:is(.dark *) {
--tw-bg-opacity: 1;
background-color: rgb(113 63 18 / var(--tw-bg-opacity));
background-color: rgb(161 98 7 / var(--tw-bg-opacity));
}
.dark\:bg-yellow-900\/50:is(.dark *) {
background-color: rgb(113 63 18 / 0.5);
}
.dark\:from-gray-950:is(.dark *) {
@@ -3703,6 +3973,11 @@ select {
color: rgb(191 219 254 / var(--tw-text-opacity));
}
.dark\:text-blue-300:is(.dark *) {
--tw-text-opacity: 1;
color: rgb(147 197 253 / var(--tw-text-opacity));
}
.dark\:text-blue-400:is(.dark *) {
--tw-text-opacity: 1;
color: rgb(96 165 250 / var(--tw-text-opacity));
@@ -3743,11 +4018,6 @@ select {
color: rgb(75 85 99 / var(--tw-text-opacity));
}
.dark\:text-green-200:is(.dark *) {
--tw-text-opacity: 1;
color: rgb(187 247 208 / var(--tw-text-opacity));
}
.dark\:text-green-400:is(.dark *) {
--tw-text-opacity: 1;
color: rgb(74 222 128 / var(--tw-text-opacity));
@@ -3758,16 +4028,16 @@ select {
color: rgb(240 253 244 / var(--tw-text-opacity));
}
.dark\:text-green-800:is(.dark *) {
--tw-text-opacity: 1;
color: rgb(22 101 52 / var(--tw-text-opacity));
}
.dark\:text-green-900:is(.dark *) {
--tw-text-opacity: 1;
color: rgb(20 83 45 / var(--tw-text-opacity));
}
.dark\:text-red-200:is(.dark *) {
--tw-text-opacity: 1;
color: rgb(254 202 202 / var(--tw-text-opacity));
}
.dark\:text-red-400:is(.dark *) {
--tw-text-opacity: 1;
color: rgb(248 113 113 / var(--tw-text-opacity));
@@ -3813,6 +4083,11 @@ select {
color: rgb(254 252 232 / var(--tw-text-opacity));
}
.dark\:text-yellow-800:is(.dark *) {
--tw-text-opacity: 1;
color: rgb(133 77 14 / var(--tw-text-opacity));
}
.dark\:ring-1:is(.dark *) {
--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);
--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);
@@ -3827,6 +4102,11 @@ select {
--tw-ring-color: rgb(250 204 21 / 0.3);
}
.dark\:hover\:bg-blue-500:hover:is(.dark *) {
--tw-bg-opacity: 1;
background-color: rgb(59 130 246 / var(--tw-bg-opacity));
}
.dark\:hover\:bg-blue-600:hover:is(.dark *) {
--tw-bg-opacity: 1;
background-color: rgb(37 99 235 / var(--tw-bg-opacity));
@@ -3837,6 +4117,10 @@ select {
background-color: rgb(30 64 175 / var(--tw-bg-opacity));
}
.dark\:hover\:bg-blue-900\/40:hover:is(.dark *) {
background-color: rgb(30 58 138 / 0.4);
}
.dark\:hover\:bg-gray-500:hover:is(.dark *) {
--tw-bg-opacity: 1;
background-color: rgb(107 114 128 / var(--tw-bg-opacity));
@@ -3866,6 +4150,11 @@ select {
background-color: rgb(127 29 29 / 0.2);
}
.dark\:hover\:bg-yellow-600:hover:is(.dark *) {
--tw-bg-opacity: 1;
background-color: rgb(202 138 4 / var(--tw-bg-opacity));
}
.dark\:hover\:text-blue-300:hover:is(.dark *) {
--tw-text-opacity: 1;
color: rgb(147 197 253 / var(--tw-text-opacity));
@@ -3876,6 +4165,11 @@ select {
color: rgb(96 165 250 / var(--tw-text-opacity));
}
.dark\:hover\:text-gray-200:hover:is(.dark *) {
--tw-text-opacity: 1;
color: rgb(229 231 235 / var(--tw-text-opacity));
}
.dark\:hover\:text-gray-300:hover:is(.dark *) {
--tw-text-opacity: 1;
color: rgb(209 213 219 / var(--tw-text-opacity));
@@ -3891,6 +4185,11 @@ select {
color: rgb(125 211 252 / var(--tw-text-opacity));
}
.dark\:hover\:text-white:hover:is(.dark *) {
--tw-text-opacity: 1;
color: rgb(255 255 255 / var(--tw-text-opacity));
}
@media (min-width: 640px) {
.sm\:col-span-3 {
grid-column: span 3 / span 3;
@@ -3990,6 +4289,14 @@ select {
}
@media (min-width: 768px) {
.md\:col-span-1 {
grid-column: span 1 / span 1;
}
.md\:col-span-2 {
grid-column: span 2 / span 2;
}
.md\:mb-8 {
margin-bottom: 2rem;
}
@@ -4024,6 +4331,11 @@ select {
margin-left: calc(0.75rem * calc(1 - var(--tw-space-x-reverse)));
}
.md\:py-12 {
padding-top: 3rem;
padding-bottom: 3rem;
}
.md\:text-2xl {
font-size: 1.5rem;
line-height: 2rem;

View File

@@ -6,8 +6,8 @@
{% block title %}Login - ThrillWiki{% endblock %}
{% block content %}
<div class="flex items-center justify-center min-h-[calc(100vh-16rem)]">
<div class="w-full max-w-lg">
<div class="flex items-center justify-center py-8 md:py-12">
<div class="w-full max-w-lg px-4">
<div class="auth-card">
<h1 class="auth-title">{% trans "Welcome Back" %}</h1>

View File

@@ -0,0 +1,71 @@
{% load i18n %}
{% load account socialaccount %}
{% load static %}
<div
id="login-modal"
class="fixed inset-0 z-50 flex items-center justify-center p-4 overflow-y-auto bg-black/50 backdrop-blur-sm"
>
<div class="w-full max-w-lg my-auto bg-white rounded-lg shadow-xl dark:bg-gray-800 max-h-[90vh] overflow-y-auto">
<div class="sticky top-0 flex justify-between p-6 bg-white border-b dark:bg-gray-800 dark:border-gray-700">
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">{% trans "Welcome Back" %}</h2>
<button
onclick="this.closest('#login-modal').remove()"
class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
>
<i class="fas fa-times"></i>
</button>
</div>
<div class="p-6">
{% get_providers as socialaccount_providers %}
{% if socialaccount_providers %}
<div class="space-y-3">
{% for provider in socialaccount_providers %}
<a
href="{% provider_login_url provider.id process='login' %}"
class="btn-social {% if provider.id == 'discord' %}btn-discord{% elif provider.id == 'google' %}btn-google{% endif %}"
role="button"
tabindex="0"
onkeydown="if(event.key === 'Enter' || event.key === ' ') { this.click(); event.preventDefault(); }"
>
{% if provider.id == 'google' %}
<img
src="{% static 'images/google-icon.svg' %}"
alt="Google"
class="w-5 h-5 mr-3"
/>
<span>Continue with Google</span>
{% elif provider.id == 'discord' %}
<img
src="{% static 'images/discord-icon.svg' %}"
alt="Discord"
class="w-5 h-5 mr-3"
/>
<span>Continue with Discord</span>
{% endif %}
</a>
{% endfor %}
</div>
<div class="auth-divider">
<span>Or continue with email</span>
</div>
{% endif %}
{% include "account/partials/login_form.html" %}
<div class="mt-6 text-sm text-center">
<p class="text-gray-600 dark:text-gray-400">
{% trans "Don't have an account?" %}
<a
href="{% url 'account_signup' %}"
class="ml-1 font-medium transition-colors text-primary hover:text-primary/80 focus:outline-none focus:underline"
>
{% trans "Sign up" %}
</a>
</p>
</div>
</div>
</div>
</div>

Some files were not shown because too many files have changed in this diff Show More