mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-23 14:31:09 -05:00
Compare commits
44 Commits
november_1
...
november20
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dfe6194039 | ||
|
|
e63677e8c0 | ||
|
|
af3e255a2e | ||
|
|
ebbf772669 | ||
|
|
891c29beff | ||
|
|
e86cb95f14 | ||
|
|
fd97ed31cb | ||
|
|
d9990ae241 | ||
|
|
da0ee1acfa | ||
|
|
30b786d51e | ||
|
|
8d70bf8994 | ||
|
|
edc9d66849 | ||
|
|
8265348a83 | ||
|
|
8f7f7add2d | ||
|
|
7ec4d964dc | ||
|
|
d68c927a00 | ||
|
|
caba5c6158 | ||
|
|
131ef7ceb0 | ||
|
|
a30f3ef644 | ||
|
|
2e1040e3a6 | ||
|
|
751d21098d | ||
|
|
1acfe9d29e | ||
|
|
6a9154ce69 | ||
|
|
15e56c9770 | ||
|
|
09ee45f6c7 | ||
|
|
177117f4d6 | ||
|
|
96341bfd82 | ||
|
|
f011d58c6d | ||
|
|
983c101ed1 | ||
|
|
97a3555e81 | ||
|
|
ec626b4124 | ||
|
|
537ea0fc07 | ||
|
|
be07a17460 | ||
|
|
1c03e4acb8 | ||
|
|
a5ebeb51dc | ||
|
|
1ee4b00961 | ||
|
|
5a1fdb6d16 | ||
|
|
78355c60f9 | ||
|
|
d8a65f4e81 | ||
|
|
cac6335bb7 | ||
|
|
7f4de7c2ec | ||
|
|
08e97f21b7 | ||
|
|
9ee380c3ea | ||
|
|
d2c9d02523 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -32,4 +32,6 @@ parks/__pycache__/views.cpython-312.pyc
|
|||||||
.venv/lib/python3.12/site-packages
|
.venv/lib/python3.12/site-packages
|
||||||
thrillwiki/__pycache__/urls.cpython-312.pyc
|
thrillwiki/__pycache__/urls.cpython-312.pyc
|
||||||
thrillwiki/__pycache__/views.cpython-312.pyc
|
thrillwiki/__pycache__/views.cpython-312.pyc
|
||||||
.pytest_cache.github
|
.pytest_cache.github
|
||||||
|
static/css/tailwind.css
|
||||||
|
static/css/tailwind.css
|
||||||
|
|||||||
@@ -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.models
|
||||||
import django.contrib.auth.validators
|
import django.contrib.auth.validators
|
||||||
@@ -60,6 +60,18 @@ class Migration(migrations.Migration):
|
|||||||
verbose_name="username",
|
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",
|
"email",
|
||||||
models.EmailField(
|
models.EmailField(
|
||||||
@@ -97,18 +109,6 @@ class Migration(migrations.Migration):
|
|||||||
unique=True,
|
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",
|
"role",
|
||||||
models.CharField(
|
models.CharField(
|
||||||
|
|||||||
@@ -5,11 +5,16 @@ from django.core.exceptions import ValidationError
|
|||||||
class TurnstileMixin:
|
class TurnstileMixin:
|
||||||
"""
|
"""
|
||||||
Mixin to handle Cloudflare Turnstile validation.
|
Mixin to handle Cloudflare Turnstile validation.
|
||||||
|
Bypasses validation when DEBUG is True.
|
||||||
"""
|
"""
|
||||||
def validate_turnstile(self, request):
|
def validate_turnstile(self, request):
|
||||||
"""
|
"""
|
||||||
Validate the Turnstile response token.
|
Validate the Turnstile response token.
|
||||||
|
Skips validation when DEBUG is True.
|
||||||
"""
|
"""
|
||||||
|
if settings.DEBUG:
|
||||||
|
return
|
||||||
|
|
||||||
token = request.POST.get('cf-turnstile-response')
|
token = request.POST.get('cf-turnstile-response')
|
||||||
if not token:
|
if not token:
|
||||||
raise ValidationError('Please complete the Turnstile challenge.')
|
raise ValidationError('Please complete the Turnstile challenge.')
|
||||||
|
|||||||
@@ -1,14 +1,24 @@
|
|||||||
from django import template
|
from django import template
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.template.loader import render_to_string
|
||||||
|
|
||||||
register = template.Library()
|
register = template.Library()
|
||||||
|
|
||||||
@register.inclusion_tag('accounts/turnstile_widget.html')
|
@register.simple_tag
|
||||||
def turnstile_widget():
|
def turnstile_widget():
|
||||||
"""
|
"""
|
||||||
Template tag to render the Cloudflare 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 %}
|
Usage: {% load turnstile_tags %}{% turnstile_widget %}
|
||||||
"""
|
"""
|
||||||
return {
|
if settings.DEBUG:
|
||||||
'site_key': settings.TURNSTILE_SITE_KEY
|
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)
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ from django_htmx.http import HttpResponseClientRefresh
|
|||||||
from django.contrib.sites.models import Site
|
from django.contrib.sites.models import Site
|
||||||
from django.contrib.sites.requests import RequestSite
|
from django.contrib.sites.requests import RequestSite
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
|
import re
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from django.contrib.sites.models import Site
|
from django.contrib.sites.models import Site
|
||||||
@@ -46,10 +47,7 @@ class CustomLoginView(TurnstileMixin, LoginView):
|
|||||||
return self.form_invalid(form)
|
return self.form_invalid(form)
|
||||||
|
|
||||||
response = super().form_valid(form)
|
response = super().form_valid(form)
|
||||||
|
return HttpResponseClientRefresh() if getattr(self.request, 'htmx', False) else response
|
||||||
if getattr(self.request, 'htmx', False):
|
|
||||||
return HttpResponseClientRefresh()
|
|
||||||
return response
|
|
||||||
|
|
||||||
def form_invalid(self, form):
|
def form_invalid(self, form):
|
||||||
if getattr(self.request, 'htmx', False):
|
if getattr(self.request, 'htmx', False):
|
||||||
@@ -64,7 +62,7 @@ class CustomLoginView(TurnstileMixin, LoginView):
|
|||||||
if getattr(request, 'htmx', False):
|
if getattr(request, 'htmx', False):
|
||||||
return render(
|
return render(
|
||||||
request,
|
request,
|
||||||
'account/partials/login_form.html',
|
'account/partials/login_modal.html',
|
||||||
self.get_context_data()
|
self.get_context_data()
|
||||||
)
|
)
|
||||||
return super().get(request, *args, **kwargs)
|
return super().get(request, *args, **kwargs)
|
||||||
@@ -76,7 +74,27 @@ class CustomSignupView(TurnstileMixin, SignupView):
|
|||||||
except ValidationError as e:
|
except ValidationError as e:
|
||||||
form.add_error(None, str(e))
|
form.add_error(None, str(e))
|
||||||
return self.form_invalid(form)
|
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
|
@login_required
|
||||||
def user_redirect_view(request: HttpRequest) -> HttpResponse:
|
def user_redirect_view(request: HttpRequest) -> HttpResponse:
|
||||||
@@ -90,7 +108,6 @@ def handle_social_login(request: HttpRequest, email: str) -> HttpResponse:
|
|||||||
login(request, sociallogin.user)
|
login(request, sociallogin.user)
|
||||||
del request.session['socialaccount_sociallogin']
|
del request.session['socialaccount_sociallogin']
|
||||||
messages.success(request, 'Successfully logged in')
|
messages.success(request, 'Successfully logged in')
|
||||||
return redirect('/')
|
|
||||||
return redirect('/')
|
return redirect('/')
|
||||||
|
|
||||||
def email_required(request: HttpRequest) -> HttpResponse:
|
def email_required(request: HttpRequest) -> HttpResponse:
|
||||||
@@ -170,27 +187,64 @@ class SettingsView(LoginRequiredMixin, TemplateView):
|
|||||||
user.save()
|
user.save()
|
||||||
messages.success(request, 'Profile updated successfully')
|
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]:
|
def _handle_password_change(self, request: HttpRequest) -> Optional[HttpResponseRedirect]:
|
||||||
user = cast(User, request.user)
|
user = cast(User, request.user)
|
||||||
old_password = request.POST.get('old_password', '')
|
old_password = request.POST.get('old_password', '')
|
||||||
new_password = request.POST.get('new_password', '')
|
new_password = request.POST.get('new_password', '')
|
||||||
|
confirm_password = request.POST.get('confirm_password', '')
|
||||||
|
|
||||||
if not user.check_password(old_password):
|
if not user.check_password(old_password):
|
||||||
messages.error(request, 'Current password is incorrect')
|
messages.error(request, 'Current password is incorrect')
|
||||||
return None
|
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.set_password(new_password)
|
||||||
user.save()
|
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'))
|
return HttpResponseRedirect(reverse('account_login'))
|
||||||
|
|
||||||
def _handle_email_change(self, request: HttpRequest) -> None:
|
def _handle_email_change(self, request: HttpRequest) -> None:
|
||||||
if not (new_email := request.POST.get('new_email')):
|
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')
|
messages.error(request, 'New email is required')
|
||||||
return
|
|
||||||
|
|
||||||
self._send_email_verification(request, new_email)
|
|
||||||
messages.success(request, 'Verification email sent to your new email address')
|
|
||||||
|
|
||||||
def _send_email_verification(self, request: HttpRequest, new_email: str) -> None:
|
def _send_email_verification(self, request: HttpRequest, new_email: str) -> None:
|
||||||
user = cast(User, request.user)
|
user = cast(User, request.user)
|
||||||
|
|||||||
@@ -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
|
import django.db.models.deletion
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
|||||||
60
cline_docs/activeContext.md
Normal file
60
cline_docs/activeContext.md
Normal 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
|
||||||
199
cline_docs/developmentWorkflow.md
Normal file
199
cline_docs/developmentWorkflow.md
Normal 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
|
||||||
215
cline_docs/operationalContext.md
Normal file
215
cline_docs/operationalContext.md
Normal 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
|
||||||
86
cline_docs/productContext.md
Normal file
86
cline_docs/productContext.md
Normal 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
|
||||||
183
cline_docs/projectBoundaries.md
Normal file
183
cline_docs/projectBoundaries.md
Normal 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
|
||||||
143
cline_docs/systemPatterns.md
Normal file
143
cline_docs/systemPatterns.md
Normal 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
254
cline_docs/techContext.md
Normal 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
|
||||||
@@ -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
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
@@ -10,96 +7,60 @@ class Migration(migrations.Migration):
|
|||||||
|
|
||||||
initial = True
|
initial = True
|
||||||
|
|
||||||
dependencies = [
|
dependencies = []
|
||||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='Company',
|
name="Company",
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
(
|
||||||
('name', models.CharField(max_length=255)),
|
"id",
|
||||||
('slug', models.SlugField(max_length=255, unique=True)),
|
models.BigAutoField(
|
||||||
('headquarters', models.CharField(blank=True, max_length=255)),
|
auto_created=True,
|
||||||
('description', models.TextField(blank=True)),
|
primary_key=True,
|
||||||
('website', models.URLField(blank=True)),
|
serialize=False,
|
||||||
('founded_date', models.DateField(blank=True, null=True)),
|
verbose_name="ID",
|
||||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
),
|
||||||
('updated_at', models.DateTimeField(auto_now=True)),
|
),
|
||||||
|
("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={
|
options={
|
||||||
'verbose_name_plural': 'companies',
|
"verbose_name_plural": "companies",
|
||||||
'ordering': ['name'],
|
"ordering": ["name"],
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='Manufacturer',
|
name="Manufacturer",
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
(
|
||||||
('name', models.CharField(max_length=255)),
|
"id",
|
||||||
('slug', models.SlugField(max_length=255, unique=True)),
|
models.BigAutoField(
|
||||||
('headquarters', models.CharField(blank=True, max_length=255)),
|
auto_created=True,
|
||||||
('description', models.TextField(blank=True)),
|
primary_key=True,
|
||||||
('website', models.URLField(blank=True)),
|
serialize=False,
|
||||||
('founded_date', models.DateField(blank=True, null=True)),
|
verbose_name="ID",
|
||||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
),
|
||||||
('updated_at', models.DateTimeField(auto_now=True)),
|
),
|
||||||
|
("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={
|
options={
|
||||||
'ordering': ['name'],
|
"ordering": ["name"],
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
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)),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'verbose_name': 'historical manufacturer',
|
|
||||||
'verbose_name_plural': 'historical manufacturers',
|
|
||||||
'ordering': ('-history_date', '-history_id'),
|
|
||||||
'get_latest_by': ('history_date', 'history_id'),
|
|
||||||
},
|
|
||||||
bases=(simple_history.models.HistoricalChanges, models.Model),
|
|
||||||
),
|
|
||||||
]
|
]
|
||||||
|
|||||||
28
companies/migrations/0002_add_designer_model.py
Normal file
28
companies/migrations/0002_add_designer_model.py
Normal 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'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -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',
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -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),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -88,3 +88,43 @@ class Manufacturer(models.Model):
|
|||||||
return cls.objects.get(pk=historical.object_id), True
|
return cls.objects.get(pk=historical.object_id), True
|
||||||
except (HistoricalSlug.DoesNotExist, cls.DoesNotExist):
|
except (HistoricalSlug.DoesNotExist, cls.DoesNotExist):
|
||||||
raise 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()
|
||||||
|
|||||||
@@ -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
|
import django.db.models.deletion
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
@@ -9,23 +9,45 @@ class Migration(migrations.Migration):
|
|||||||
initial = True
|
initial = True
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
('contenttypes', '0002_remove_content_type_name'),
|
("contenttypes", "0002_remove_content_type_name"),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='SlugHistory',
|
name="SlugHistory",
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
(
|
||||||
('object_id', models.CharField(max_length=50)),
|
"id",
|
||||||
('old_slug', models.SlugField(max_length=200)),
|
models.BigAutoField(
|
||||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
auto_created=True,
|
||||||
('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')),
|
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={
|
options={
|
||||||
'verbose_name_plural': 'Slug histories',
|
"verbose_name_plural": "Slug histories",
|
||||||
'ordering': ['-created_at'],
|
"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')],
|
"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"
|
||||||
|
),
|
||||||
|
],
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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 django.db.models.deletion
|
||||||
import simple_history.models
|
import simple_history.models
|
||||||
|
|||||||
@@ -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
|
import django.db.models.deletion
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
@@ -9,25 +9,44 @@ class Migration(migrations.Migration):
|
|||||||
initial = True
|
initial = True
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
('sites', '0002_alter_domain_unique'),
|
("sites", "0002_alter_domain_unique"),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='EmailConfiguration',
|
name="EmailConfiguration",
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
(
|
||||||
('api_key', models.CharField(max_length=255)),
|
"id",
|
||||||
('from_email', models.EmailField(max_length=254)),
|
models.BigAutoField(
|
||||||
('from_name', models.CharField(help_text='The name that will appear in the From field of emails', max_length=255)),
|
auto_created=True,
|
||||||
('reply_to', models.EmailField(max_length=254)),
|
primary_key=True,
|
||||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
serialize=False,
|
||||||
('updated_at', models.DateTimeField(auto_now=True)),
|
verbose_name="ID",
|
||||||
('site', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='sites.site')),
|
),
|
||||||
|
),
|
||||||
|
("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={
|
options={
|
||||||
'verbose_name': 'Email Configuration',
|
"verbose_name": "Email Configuration",
|
||||||
'verbose_name_plural': 'Email Configurations',
|
"verbose_name_plural": "Email Configurations",
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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 django.db.models.deletion
|
||||||
import history_tracking.mixins
|
|
||||||
import simple_history.models
|
|
||||||
from django.conf import settings
|
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
@@ -12,12 +9,12 @@ class Migration(migrations.Migration):
|
|||||||
initial = True
|
initial = True
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
("contenttypes", "0002_remove_content_type_name"),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name="Park",
|
name="HistoricalSlug",
|
||||||
fields=[
|
fields=[
|
||||||
(
|
(
|
||||||
"id",
|
"id",
|
||||||
@@ -28,49 +25,26 @@ class Migration(migrations.Migration):
|
|||||||
verbose_name="ID",
|
verbose_name="ID",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
("name", models.CharField(max_length=200)),
|
("object_id", models.PositiveIntegerField()),
|
||||||
],
|
("slug", models.SlugField(max_length=255)),
|
||||||
),
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
migrations.CreateModel(
|
|
||||||
name="HistoricalPark",
|
|
||||||
fields=[
|
|
||||||
(
|
(
|
||||||
"id",
|
"content_type",
|
||||||
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",
|
|
||||||
models.ForeignKey(
|
models.ForeignKey(
|
||||||
null=True,
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
on_delete=django.db.models.deletion.SET_NULL,
|
to="contenttypes.contenttype",
|
||||||
related_name="+",
|
|
||||||
to=settings.AUTH_USER_MODEL,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
options={
|
options={
|
||||||
"verbose_name": "historical park",
|
"indexes": [
|
||||||
"verbose_name_plural": "historical parks",
|
models.Index(
|
||||||
"ordering": ("-history_date", "-history_id"),
|
fields=["content_type", "object_id"],
|
||||||
"get_latest_by": ("history_date", "history_id"),
|
name="history_tra_content_63013c_idx",
|
||||||
|
),
|
||||||
|
models.Index(fields=["slug"], name="history_tra_slug_f843aa_idx"),
|
||||||
|
],
|
||||||
|
"unique_together": {("content_type", "slug")},
|
||||||
},
|
},
|
||||||
bases=(
|
|
||||||
history_tracking.mixins.HistoricalChangeMixin,
|
|
||||||
simple_history.models.HistoricalChanges,
|
|
||||||
models.Model,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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",
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -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")},
|
|
||||||
},
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,12 +1,30 @@
|
|||||||
# history_tracking/mixins.py
|
# 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
|
@property
|
||||||
def prev_record(self):
|
def prev_record(self):
|
||||||
"""Get the previous record for this instance"""
|
"""Get the previous record for this instance"""
|
||||||
try:
|
try:
|
||||||
return type(self).objects.filter(
|
return self.__class__.objects.filter(
|
||||||
history_date__lt=self.history_date,
|
history_date__lt=self.history_date,
|
||||||
id=self.id
|
id=self.id
|
||||||
).order_by('-history_date').first()
|
).order_by('-history_date').first()
|
||||||
@@ -28,12 +46,29 @@ class HistoricalChangeMixin:
|
|||||||
"history_user_id",
|
"history_user_id",
|
||||||
"history_change_reason",
|
"history_change_reason",
|
||||||
"history_type",
|
"history_type",
|
||||||
|
"id",
|
||||||
|
"_state",
|
||||||
|
"_history_user_cache"
|
||||||
] and not field.startswith("_"):
|
] and not field.startswith("_"):
|
||||||
try:
|
try:
|
||||||
old_value = getattr(prev_record, field)
|
old_value = getattr(prev_record, field)
|
||||||
new_value = getattr(self, field)
|
new_value = getattr(self, field)
|
||||||
if old_value != new_value:
|
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:
|
except AttributeError:
|
||||||
continue
|
continue
|
||||||
return changes
|
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
|
||||||
|
|||||||
@@ -5,12 +5,17 @@ from django.contrib.contenttypes.fields import GenericForeignKey
|
|||||||
from simple_history.models import HistoricalRecords
|
from simple_history.models import HistoricalRecords
|
||||||
from .mixins import HistoricalChangeMixin
|
from .mixins import HistoricalChangeMixin
|
||||||
from typing import Any, Type, TypeVar, cast
|
from typing import Any, Type, TypeVar, cast
|
||||||
|
from django.db.models import QuerySet
|
||||||
|
|
||||||
T = TypeVar('T', bound=models.Model)
|
T = TypeVar('T', bound=models.Model)
|
||||||
|
|
||||||
class HistoricalModel(models.Model):
|
class HistoricalModel(models.Model):
|
||||||
"""Abstract base class for models with history tracking"""
|
"""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:
|
class Meta:
|
||||||
abstract = True
|
abstract = True
|
||||||
@@ -20,6 +25,11 @@ class HistoricalModel(models.Model):
|
|||||||
"""Get the history model class"""
|
"""Get the history model class"""
|
||||||
return cast(Type[T], self.history.model) # type: ignore
|
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):
|
class HistoricalSlug(models.Model):
|
||||||
"""Track historical slugs for models"""
|
"""Track historical slugs for models"""
|
||||||
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
|
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
|
||||||
|
|||||||
@@ -1,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.core.validators
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
import simple_history.models
|
import simple_history.models
|
||||||
@@ -44,9 +45,11 @@ class Migration(migrations.Migration):
|
|||||||
(
|
(
|
||||||
"latitude",
|
"latitude",
|
||||||
models.DecimalField(
|
models.DecimalField(
|
||||||
|
blank=True,
|
||||||
decimal_places=6,
|
decimal_places=6,
|
||||||
help_text="Latitude coordinate",
|
help_text="Latitude coordinate (legacy field)",
|
||||||
max_digits=9,
|
max_digits=9,
|
||||||
|
null=True,
|
||||||
validators=[
|
validators=[
|
||||||
django.core.validators.MinValueValidator(-90),
|
django.core.validators.MinValueValidator(-90),
|
||||||
django.core.validators.MaxValueValidator(90),
|
django.core.validators.MaxValueValidator(90),
|
||||||
@@ -56,25 +59,42 @@ class Migration(migrations.Migration):
|
|||||||
(
|
(
|
||||||
"longitude",
|
"longitude",
|
||||||
models.DecimalField(
|
models.DecimalField(
|
||||||
|
blank=True,
|
||||||
decimal_places=6,
|
decimal_places=6,
|
||||||
help_text="Longitude coordinate",
|
help_text="Longitude coordinate (legacy field)",
|
||||||
max_digits=9,
|
max_digits=9,
|
||||||
|
null=True,
|
||||||
validators=[
|
validators=[
|
||||||
django.core.validators.MinValueValidator(-180),
|
django.core.validators.MinValueValidator(-180),
|
||||||
django.core.validators.MaxValueValidator(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",
|
"state",
|
||||||
models.CharField(
|
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)),
|
("country", models.CharField(blank=True, max_length=100, null=True)),
|
||||||
("postal_code", models.CharField(blank=True, max_length=20)),
|
("postal_code", models.CharField(blank=True, max_length=20, null=True)),
|
||||||
("created_at", models.DateTimeField(blank=True, editable=False)),
|
("created_at", models.DateTimeField(blank=True, editable=False)),
|
||||||
("updated_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_id", models.AutoField(primary_key=True, serialize=False)),
|
||||||
@@ -146,9 +166,11 @@ class Migration(migrations.Migration):
|
|||||||
(
|
(
|
||||||
"latitude",
|
"latitude",
|
||||||
models.DecimalField(
|
models.DecimalField(
|
||||||
|
blank=True,
|
||||||
decimal_places=6,
|
decimal_places=6,
|
||||||
help_text="Latitude coordinate",
|
help_text="Latitude coordinate (legacy field)",
|
||||||
max_digits=9,
|
max_digits=9,
|
||||||
|
null=True,
|
||||||
validators=[
|
validators=[
|
||||||
django.core.validators.MinValueValidator(-90),
|
django.core.validators.MinValueValidator(-90),
|
||||||
django.core.validators.MaxValueValidator(90),
|
django.core.validators.MaxValueValidator(90),
|
||||||
@@ -158,25 +180,42 @@ class Migration(migrations.Migration):
|
|||||||
(
|
(
|
||||||
"longitude",
|
"longitude",
|
||||||
models.DecimalField(
|
models.DecimalField(
|
||||||
|
blank=True,
|
||||||
decimal_places=6,
|
decimal_places=6,
|
||||||
help_text="Longitude coordinate",
|
help_text="Longitude coordinate (legacy field)",
|
||||||
max_digits=9,
|
max_digits=9,
|
||||||
|
null=True,
|
||||||
validators=[
|
validators=[
|
||||||
django.core.validators.MinValueValidator(-180),
|
django.core.validators.MinValueValidator(-180),
|
||||||
django.core.validators.MaxValueValidator(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",
|
"state",
|
||||||
models.CharField(
|
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)),
|
("country", models.CharField(blank=True, max_length=100, null=True)),
|
||||||
("postal_code", models.CharField(blank=True, max_length=20)),
|
("postal_code", models.CharField(blank=True, max_length=20, null=True)),
|
||||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
("updated_at", models.DateTimeField(auto_now=True)),
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
(
|
(
|
||||||
@@ -194,10 +233,6 @@ class Migration(migrations.Migration):
|
|||||||
fields=["content_type", "object_id"],
|
fields=["content_type", "object_id"],
|
||||||
name="location_lo_content_9ee1bd_idx",
|
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=["city"], name="location_lo_city_99f908_idx"),
|
||||||
models.Index(
|
models.Index(
|
||||||
fields=["country"], name="location_lo_country_b75eba_idx"
|
fields=["country"], name="location_lo_country_b75eba_idx"
|
||||||
|
|||||||
@@ -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),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -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),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -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",
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -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),
|
|
||||||
]
|
|
||||||
@@ -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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -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 django.db.models.deletion
|
||||||
import media.models
|
import media.models
|
||||||
|
import media.storage
|
||||||
|
from django.conf import settings
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
@@ -10,26 +12,64 @@ class Migration(migrations.Migration):
|
|||||||
initial = True
|
initial = True
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
('contenttypes', '0002_remove_content_type_name'),
|
("contenttypes", "0002_remove_content_type_name"),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='Photo',
|
name="Photo",
|
||||||
fields=[
|
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)),
|
"id",
|
||||||
('caption', models.CharField(blank=True, max_length=255)),
|
models.BigAutoField(
|
||||||
('alt_text', models.CharField(blank=True, max_length=255)),
|
auto_created=True,
|
||||||
('is_primary', models.BooleanField(default=False)),
|
primary_key=True,
|
||||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
serialize=False,
|
||||||
('updated_at', models.DateTimeField(auto_now=True)),
|
verbose_name="ID",
|
||||||
('object_id', models.PositiveIntegerField()),
|
),
|
||||||
('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')),
|
),
|
||||||
|
(
|
||||||
|
"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={
|
options={
|
||||||
'ordering': ['-is_primary', '-created_at'],
|
"ordering": ["-is_primary", "-created_at"],
|
||||||
'indexes': [models.Index(fields=['content_type', 'object_id'], name='media_photo_content_0187f5_idx')],
|
"indexes": [
|
||||||
|
models.Index(
|
||||||
|
fields=["content_type", "object_id"],
|
||||||
|
name="media_photo_content_0187f5_idx",
|
||||||
|
)
|
||||||
|
],
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -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),
|
|
||||||
]
|
|
||||||
@@ -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
|
|
||||||
]
|
|
||||||
@@ -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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -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),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -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),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
BIN
media/submissions/photos/coaster_track.gif
Normal file
BIN
media/submissions/photos/coaster_track.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 35 B |
BIN
media/submissions/photos/park_entrance.gif
Normal file
BIN
media/submissions/photos/park_entrance.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 35 B |
BIN
media/submissions/photos/test_image.gif
Normal file
BIN
media/submissions/photos/test_image.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 35 B |
BIN
media/submissions/photos/test_image2.gif
Normal file
BIN
media/submissions/photos/test_image2.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 35 B |
BIN
media/submissions/photos/test_image2_ruT57k4.gif
Normal file
BIN
media/submissions/photos/test_image2_ruT57k4.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 35 B |
BIN
media/submissions/photos/test_image_iI0mcgf.gif
Normal file
BIN
media/submissions/photos/test_image_iI0mcgf.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 35 B |
@@ -9,8 +9,18 @@ def moderation_access(request):
|
|||||||
|
|
||||||
if request.user.is_authenticated:
|
if request.user.is_authenticated:
|
||||||
context['user_role'] = request.user.role
|
context['user_role'] = request.user.role
|
||||||
context['has_moderation_access'] = request.user.role in ['MODERATOR', 'ADMIN', 'SUPERUSER']
|
# Check both role-based and Django's built-in superuser status
|
||||||
context['has_admin_access'] = request.user.role in ['ADMIN', 'SUPERUSER']
|
context['has_moderation_access'] = (
|
||||||
context['has_superuser_access'] = request.user.role == 'SUPERUSER'
|
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
|
return context
|
||||||
|
|||||||
0
moderation/management/__init__.py
Normal file
0
moderation/management/__init__.py
Normal file
0
moderation/management/commands/__init__.py
Normal file
0
moderation/management/commands/__init__.py
Normal file
228
moderation/management/commands/seed_submissions.py
Normal file
228
moderation/management/commands/seed_submissions.py
Normal 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'))
|
||||||
@@ -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
|
import django.db.models.deletion
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
@@ -27,38 +27,48 @@ class Migration(migrations.Migration):
|
|||||||
verbose_name="ID",
|
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",
|
"changes",
|
||||||
models.JSONField(
|
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",
|
"source",
|
||||||
models.TextField(
|
models.TextField(
|
||||||
blank=True,
|
blank=True, help_text="Source of information (if applicable)"
|
||||||
help_text="Source of information for this edit (if applicable)",
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"status",
|
"status",
|
||||||
models.CharField(
|
models.CharField(
|
||||||
choices=[
|
choices=[
|
||||||
("PENDING", "Pending"),
|
("NEW", "New"),
|
||||||
("APPROVED", "Approved"),
|
("APPROVED", "Approved"),
|
||||||
("REJECTED", "Rejected"),
|
("REJECTED", "Rejected"),
|
||||||
("AUTO_APPROVED", "Auto Approved"),
|
("ESCALATED", "Escalated"),
|
||||||
],
|
],
|
||||||
default="PENDING",
|
default="NEW",
|
||||||
max_length=20,
|
max_length=20,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
("submitted_at", models.DateTimeField(auto_now_add=True)),
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
("reviewed_at", models.DateTimeField(blank=True, null=True)),
|
("handled_at", models.DateTimeField(blank=True, null=True)),
|
||||||
(
|
(
|
||||||
"review_notes",
|
"notes",
|
||||||
models.TextField(
|
models.TextField(
|
||||||
blank=True,
|
blank=True,
|
||||||
help_text="Notes from the moderator about this submission",
|
help_text="Notes from the moderator about this submission",
|
||||||
@@ -72,12 +82,12 @@ class Migration(migrations.Migration):
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"reviewed_by",
|
"handled_by",
|
||||||
models.ForeignKey(
|
models.ForeignKey(
|
||||||
blank=True,
|
blank=True,
|
||||||
null=True,
|
null=True,
|
||||||
on_delete=django.db.models.deletion.SET_NULL,
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
related_name="reviewed_submissions",
|
related_name="handled_submissions",
|
||||||
to=settings.AUTH_USER_MODEL,
|
to=settings.AUTH_USER_MODEL,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -91,7 +101,7 @@ class Migration(migrations.Migration):
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
options={
|
options={
|
||||||
"ordering": ["-submitted_at"],
|
"ordering": ["-created_at"],
|
||||||
"indexes": [
|
"indexes": [
|
||||||
models.Index(
|
models.Index(
|
||||||
fields=["content_type", "object_id"],
|
fields=["content_type", "object_id"],
|
||||||
@@ -123,19 +133,19 @@ class Migration(migrations.Migration):
|
|||||||
"status",
|
"status",
|
||||||
models.CharField(
|
models.CharField(
|
||||||
choices=[
|
choices=[
|
||||||
("PENDING", "Pending"),
|
("NEW", "New"),
|
||||||
("APPROVED", "Approved"),
|
("APPROVED", "Approved"),
|
||||||
("REJECTED", "Rejected"),
|
("REJECTED", "Rejected"),
|
||||||
("AUTO_APPROVED", "Auto Approved"),
|
("AUTO_APPROVED", "Auto Approved"),
|
||||||
],
|
],
|
||||||
default="PENDING",
|
default="NEW",
|
||||||
max_length=20,
|
max_length=20,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
("submitted_at", models.DateTimeField(auto_now_add=True)),
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
("reviewed_at", models.DateTimeField(blank=True, null=True)),
|
("handled_at", models.DateTimeField(blank=True, null=True)),
|
||||||
(
|
(
|
||||||
"review_notes",
|
"notes",
|
||||||
models.TextField(
|
models.TextField(
|
||||||
blank=True,
|
blank=True,
|
||||||
help_text="Notes from the moderator about this photo submission",
|
help_text="Notes from the moderator about this photo submission",
|
||||||
@@ -149,12 +159,12 @@ class Migration(migrations.Migration):
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"reviewed_by",
|
"handled_by",
|
||||||
models.ForeignKey(
|
models.ForeignKey(
|
||||||
blank=True,
|
blank=True,
|
||||||
null=True,
|
null=True,
|
||||||
on_delete=django.db.models.deletion.SET_NULL,
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
related_name="reviewed_photos",
|
related_name="handled_photos",
|
||||||
to=settings.AUTH_USER_MODEL,
|
to=settings.AUTH_USER_MODEL,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -168,7 +178,7 @@ class Migration(migrations.Migration):
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
options={
|
options={
|
||||||
"ordering": ["-submitted_at"],
|
"ordering": ["-created_at"],
|
||||||
"indexes": [
|
"indexes": [
|
||||||
models.Index(
|
models.Index(
|
||||||
fields=["content_type", "object_id"],
|
fields=["content_type", "object_id"],
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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)"
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -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',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
32
moderation/migrations/0003_update_existing_statuses.py
Normal file
32
moderation/migrations/0003_update_existing_statuses.py
Normal 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),
|
||||||
|
]
|
||||||
22
moderation/migrations/0004_add_moderator_changes.py
Normal file
22
moderation/migrations/0004_add_moderator_changes.py
Normal 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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"]},
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -8,221 +8,256 @@ from django.apps import apps
|
|||||||
from django.core.exceptions import ObjectDoesNotExist, FieldDoesNotExist
|
from django.core.exceptions import ObjectDoesNotExist, FieldDoesNotExist
|
||||||
from django.contrib.auth.base_user import AbstractBaseUser
|
from django.contrib.auth.base_user import AbstractBaseUser
|
||||||
from django.contrib.auth.models import AnonymousUser
|
from django.contrib.auth.models import AnonymousUser
|
||||||
|
from django.utils.text import slugify
|
||||||
|
|
||||||
UserType = Union[AbstractBaseUser, AnonymousUser]
|
UserType = Union[AbstractBaseUser, AnonymousUser]
|
||||||
|
|
||||||
|
|
||||||
class EditSubmission(models.Model):
|
class EditSubmission(models.Model):
|
||||||
STATUS_CHOICES = [
|
STATUS_CHOICES = [
|
||||||
('NEW', 'New'),
|
("PENDING", "Pending"),
|
||||||
('APPROVED', 'Approved'),
|
("APPROVED", "Approved"),
|
||||||
('REJECTED', 'Rejected'),
|
("REJECTED", "Rejected"),
|
||||||
('ESCALATED', 'Escalated'),
|
("ESCALATED", "Escalated"),
|
||||||
]
|
]
|
||||||
|
|
||||||
SUBMISSION_TYPE_CHOICES = [
|
SUBMISSION_TYPE_CHOICES = [
|
||||||
('EDIT', 'Edit Existing'),
|
("EDIT", "Edit Existing"),
|
||||||
('CREATE', 'Create New'),
|
("CREATE", "Create New"),
|
||||||
]
|
]
|
||||||
|
|
||||||
# Who submitted the edit
|
# Who submitted the edit
|
||||||
user = models.ForeignKey(
|
user = models.ForeignKey(
|
||||||
settings.AUTH_USER_MODEL,
|
settings.AUTH_USER_MODEL,
|
||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
related_name='edit_submissions'
|
related_name="edit_submissions",
|
||||||
)
|
)
|
||||||
|
|
||||||
# What is being edited (Park or Ride)
|
# What is being edited (Park or Ride)
|
||||||
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
|
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
|
||||||
object_id = models.PositiveIntegerField(null=True, blank=True) # Null for new objects
|
object_id = models.PositiveIntegerField(
|
||||||
content_object = GenericForeignKey('content_type', 'object_id')
|
null=True, blank=True
|
||||||
|
) # Null for new objects
|
||||||
|
content_object = GenericForeignKey("content_type", "object_id")
|
||||||
|
|
||||||
# Type of submission
|
# Type of submission
|
||||||
submission_type = models.CharField(
|
submission_type = models.CharField(
|
||||||
max_length=10,
|
max_length=10, choices=SUBMISSION_TYPE_CHOICES, default="EDIT"
|
||||||
choices=SUBMISSION_TYPE_CHOICES,
|
|
||||||
default='EDIT'
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# The actual changes/data
|
# The actual changes/data
|
||||||
changes = models.JSONField(
|
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"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Metadata
|
# Moderator's edited version of changes before approval
|
||||||
reason = models.TextField(
|
moderator_changes = models.JSONField(
|
||||||
help_text='Why this edit/addition is needed'
|
null=True,
|
||||||
)
|
|
||||||
source = models.TextField(
|
|
||||||
blank=True,
|
blank=True,
|
||||||
help_text='Source of information (if applicable)'
|
help_text="Moderator's edited version of the changes before approval"
|
||||||
)
|
)
|
||||||
status = models.CharField(
|
|
||||||
max_length=20,
|
# Metadata
|
||||||
choices=STATUS_CHOICES,
|
reason = models.TextField(help_text="Why this edit/addition is needed")
|
||||||
default='NEW'
|
source = models.TextField(
|
||||||
|
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)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
# Review details
|
# Review details
|
||||||
handled_by = models.ForeignKey(
|
handled_by = models.ForeignKey(
|
||||||
settings.AUTH_USER_MODEL,
|
settings.AUTH_USER_MODEL,
|
||||||
on_delete=models.SET_NULL,
|
on_delete=models.SET_NULL,
|
||||||
null=True,
|
null=True,
|
||||||
blank=True,
|
blank=True,
|
||||||
related_name='handled_submissions'
|
related_name="handled_submissions",
|
||||||
)
|
)
|
||||||
handled_at = models.DateTimeField(null=True, blank=True)
|
handled_at = models.DateTimeField(null=True, blank=True)
|
||||||
notes = models.TextField(
|
notes = models.TextField(
|
||||||
blank=True,
|
blank=True, help_text="Notes from the moderator about this submission"
|
||||||
help_text='Notes from the moderator about this submission'
|
|
||||||
)
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ['-created_at']
|
ordering = ["-created_at"]
|
||||||
indexes = [
|
indexes = [
|
||||||
models.Index(fields=['content_type', 'object_id']),
|
models.Index(fields=["content_type", "object_id"]),
|
||||||
models.Index(fields=['status']),
|
models.Index(fields=["status"]),
|
||||||
]
|
]
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
action = "creation" if self.submission_type == 'CREATE' else "edit"
|
action = "creation" if self.submission_type == "CREATE" else "edit"
|
||||||
model_class = self.content_type.model_class()
|
if model_class := self.content_type.model_class():
|
||||||
target = self.content_object or (model_class.__name__ if model_class else 'Unknown')
|
target = self.content_object or model_class.__name__
|
||||||
|
else:
|
||||||
|
target = "Unknown"
|
||||||
return f"{action} by {self.user.username} on {target}"
|
return f"{action} by {self.user.username} on {target}"
|
||||||
|
|
||||||
def _resolve_foreign_keys(self, data: Dict[str, Any]) -> Dict[str, Any]:
|
def _resolve_foreign_keys(self, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
"""Convert foreign key IDs to model instances"""
|
"""Convert foreign key IDs to model instances"""
|
||||||
model_class = self.content_type.model_class()
|
if not (model_class := self.content_type.model_class()):
|
||||||
if not model_class:
|
|
||||||
raise ValueError("Could not resolve model class")
|
raise ValueError("Could not resolve model class")
|
||||||
|
|
||||||
resolved_data = data.copy()
|
resolved_data = data.copy()
|
||||||
|
|
||||||
for field_name, value in data.items():
|
for field_name, value in data.items():
|
||||||
try:
|
try:
|
||||||
field = model_class._meta.get_field(field_name)
|
if (field := model_class._meta.get_field(field_name)) and isinstance(field, models.ForeignKey) and value is not None:
|
||||||
if isinstance(field, models.ForeignKey) and value is not None:
|
if related_model := field.related_model:
|
||||||
related_model = field.related_model
|
|
||||||
if related_model:
|
|
||||||
resolved_data[field_name] = related_model.objects.get(id=value)
|
resolved_data[field_name] = related_model.objects.get(id=value)
|
||||||
except (FieldDoesNotExist, ObjectDoesNotExist):
|
except (FieldDoesNotExist, ObjectDoesNotExist):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
return resolved_data
|
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]:
|
def approve(self, user: UserType) -> Optional[models.Model]:
|
||||||
"""Approve the submission and apply the changes"""
|
"""Approve the submission and apply the changes"""
|
||||||
self.status = 'APPROVED'
|
if not (model_class := self.content_type.model_class()):
|
||||||
self.handled_by = user # type: ignore
|
|
||||||
self.handled_at = timezone.now()
|
|
||||||
|
|
||||||
model_class = self.content_type.model_class()
|
|
||||||
if not model_class:
|
|
||||||
raise ValueError("Could not resolve model class")
|
raise ValueError("Could not resolve model class")
|
||||||
|
|
||||||
try:
|
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
|
||||||
|
|
||||||
|
resolved_data = self._resolve_foreign_keys(changes_to_apply)
|
||||||
|
prepared_data = self._prepare_model_data(resolved_data, model_class)
|
||||||
|
|
||||||
if self.submission_type == 'CREATE':
|
# 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
|
# Create new object
|
||||||
obj = model_class(**resolved_data)
|
obj = model_class(**prepared_data)
|
||||||
obj.save()
|
obj.save()
|
||||||
# Update object_id after creation
|
# Update object_id after creation
|
||||||
self.object_id = getattr(obj, 'id', None)
|
self.object_id = getattr(obj, "id", None)
|
||||||
else:
|
else:
|
||||||
# Apply changes to existing object
|
# Apply changes to existing object
|
||||||
obj = self.content_object
|
if not (obj := self.content_object):
|
||||||
if not obj:
|
|
||||||
raise ValueError("Content object not found")
|
raise ValueError("Content object not found")
|
||||||
for field, value in resolved_data.items():
|
for field, value in prepared_data.items():
|
||||||
setattr(obj, field, value)
|
setattr(obj, field, value)
|
||||||
obj.save()
|
obj.save()
|
||||||
|
|
||||||
self.save()
|
self.save()
|
||||||
return obj
|
return obj
|
||||||
except Exception as e:
|
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
|
raise ValueError(f"Error approving submission: {str(e)}") from e
|
||||||
|
|
||||||
def reject(self, user: UserType) -> None:
|
def reject(self, user: UserType) -> None:
|
||||||
"""Reject the submission"""
|
"""Reject the submission"""
|
||||||
self.status = 'REJECTED'
|
self.status = "REJECTED"
|
||||||
self.handled_by = user # type: ignore
|
self.handled_by = user # type: ignore
|
||||||
self.handled_at = timezone.now()
|
self.handled_at = timezone.now()
|
||||||
self.save()
|
self.save()
|
||||||
|
|
||||||
def escalate(self, user: UserType) -> None:
|
def escalate(self, user: UserType) -> None:
|
||||||
"""Escalate the submission to admin"""
|
"""Escalate the submission to admin"""
|
||||||
self.status = 'ESCALATED'
|
self.status = "ESCALATED"
|
||||||
self.handled_by = user # type: ignore
|
self.handled_by = user # type: ignore
|
||||||
self.handled_at = timezone.now()
|
self.handled_at = timezone.now()
|
||||||
self.save()
|
self.save()
|
||||||
|
|
||||||
|
|
||||||
class PhotoSubmission(models.Model):
|
class PhotoSubmission(models.Model):
|
||||||
STATUS_CHOICES = [
|
STATUS_CHOICES = [
|
||||||
('NEW', 'New'),
|
("PENDING", "Pending"),
|
||||||
('APPROVED', 'Approved'),
|
("APPROVED", "Approved"),
|
||||||
('REJECTED', 'Rejected'),
|
("REJECTED", "Rejected"),
|
||||||
('AUTO_APPROVED', 'Auto Approved'),
|
("ESCALATED", "Escalated"),
|
||||||
]
|
]
|
||||||
|
|
||||||
# Who submitted the photo
|
# Who submitted the photo
|
||||||
user = models.ForeignKey(
|
user = models.ForeignKey(
|
||||||
settings.AUTH_USER_MODEL,
|
settings.AUTH_USER_MODEL,
|
||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
related_name='photo_submissions'
|
related_name="photo_submissions",
|
||||||
)
|
)
|
||||||
|
|
||||||
# What the photo is for (Park or Ride)
|
# What the photo is for (Park or Ride)
|
||||||
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
|
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
|
||||||
object_id = models.PositiveIntegerField()
|
object_id = models.PositiveIntegerField()
|
||||||
content_object = GenericForeignKey('content_type', 'object_id')
|
content_object = GenericForeignKey("content_type", "object_id")
|
||||||
|
|
||||||
# The photo itself
|
# 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)
|
caption = models.CharField(max_length=255, blank=True)
|
||||||
date_taken = models.DateField(null=True, blank=True)
|
date_taken = models.DateField(null=True, blank=True)
|
||||||
|
|
||||||
# Metadata
|
# Metadata
|
||||||
status = models.CharField(
|
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default="PENDING")
|
||||||
max_length=20,
|
|
||||||
choices=STATUS_CHOICES,
|
|
||||||
default='NEW'
|
|
||||||
)
|
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
# Review details
|
# Review details
|
||||||
handled_by = models.ForeignKey(
|
handled_by = models.ForeignKey(
|
||||||
settings.AUTH_USER_MODEL,
|
settings.AUTH_USER_MODEL,
|
||||||
on_delete=models.SET_NULL,
|
on_delete=models.SET_NULL,
|
||||||
null=True,
|
null=True,
|
||||||
blank=True,
|
blank=True,
|
||||||
related_name='handled_photos'
|
related_name="handled_photos",
|
||||||
)
|
)
|
||||||
handled_at = models.DateTimeField(null=True, blank=True)
|
handled_at = models.DateTimeField(null=True, blank=True)
|
||||||
notes = models.TextField(
|
notes = models.TextField(
|
||||||
blank=True,
|
blank=True, help_text="Notes from the moderator about this photo submission"
|
||||||
help_text='Notes from the moderator about this photo submission'
|
|
||||||
)
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ['-created_at']
|
ordering = ["-created_at"]
|
||||||
indexes = [
|
indexes = [
|
||||||
models.Index(fields=['content_type', 'object_id']),
|
models.Index(fields=["content_type", "object_id"]),
|
||||||
models.Index(fields=['status']),
|
models.Index(fields=["status"]),
|
||||||
]
|
]
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
return f"Photo submission by {self.user.username} for {self.content_object}"
|
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"""
|
"""Approve the photo submission"""
|
||||||
from media.models import Photo
|
from media.models import Photo
|
||||||
|
|
||||||
self.status = 'APPROVED'
|
self.status = "APPROVED"
|
||||||
self.handled_by = moderator # type: ignore
|
self.handled_by = moderator # type: ignore
|
||||||
self.handled_at = timezone.now()
|
self.handled_at = timezone.now()
|
||||||
self.notes = notes
|
self.notes = notes
|
||||||
|
|
||||||
# Create the approved photo
|
# Create the approved photo
|
||||||
Photo.objects.create(
|
Photo.objects.create(
|
||||||
uploaded_by=self.user,
|
uploaded_by=self.user,
|
||||||
@@ -230,35 +265,23 @@ class PhotoSubmission(models.Model):
|
|||||||
object_id=self.object_id,
|
object_id=self.object_id,
|
||||||
image=self.photo,
|
image=self.photo,
|
||||||
caption=self.caption,
|
caption=self.caption,
|
||||||
is_approved=True
|
is_approved=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
self.save()
|
self.save()
|
||||||
|
|
||||||
def reject(self, moderator: UserType, notes: str) -> None:
|
def reject(self, moderator: UserType, notes: str) -> None:
|
||||||
"""Reject the photo submission"""
|
"""Reject the photo submission"""
|
||||||
self.status = 'REJECTED'
|
self.status = "REJECTED"
|
||||||
self.handled_by = moderator # type: ignore
|
self.handled_by = moderator # type: ignore
|
||||||
self.handled_at = timezone.now()
|
self.handled_at = timezone.now()
|
||||||
self.notes = notes
|
self.notes = notes
|
||||||
self.save()
|
self.save()
|
||||||
|
|
||||||
def auto_approve(self) -> None:
|
def escalate(self, moderator: UserType, notes: str = "") -> None:
|
||||||
"""Auto-approve the photo submission (for moderators/admins)"""
|
"""Escalate the photo submission to admin"""
|
||||||
from media.models import Photo
|
self.status = "ESCALATED"
|
||||||
|
self.handled_by = moderator # type: ignore
|
||||||
self.status = 'AUTO_APPROVED'
|
|
||||||
self.handled_by = self.user
|
|
||||||
self.handled_at = timezone.now()
|
self.handled_at = timezone.now()
|
||||||
|
self.notes = notes
|
||||||
# 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.save()
|
self.save()
|
||||||
|
|||||||
62
moderation/templatetags/moderation_tags.py
Normal file
62
moderation/templatetags/moderation_tags.py
Normal 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), [])
|
||||||
@@ -1,11 +1,34 @@
|
|||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
from django.shortcuts import redirect
|
||||||
|
from django.urls import reverse_lazy
|
||||||
from . import views
|
from . import views
|
||||||
|
|
||||||
app_name = 'moderation'
|
app_name = 'moderation'
|
||||||
|
|
||||||
|
def redirect_to_dashboard(request):
|
||||||
|
return redirect(reverse_lazy('moderation:dashboard'))
|
||||||
|
|
||||||
urlpatterns = [
|
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>/approve/', views.approve_submission, name='approve_submission'),
|
||||||
path('submissions/<int:submission_id>/reject/', views.reject_submission, name='reject_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'),
|
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'),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
|
||||||
from django.shortcuts import get_object_or_404
|
from django.contrib.auth.decorators import login_required
|
||||||
from django.http import HttpResponse
|
from django.template.loader import render_to_string
|
||||||
from django.contrib import messages
|
from django.db.models import Q, QuerySet
|
||||||
from django.db.models import Q
|
from django.core.exceptions import PermissionDenied
|
||||||
from .models import EditSubmission
|
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):
|
class ModeratorRequiredMixin(UserPassesTestMixin):
|
||||||
def test_func(self):
|
request: HttpRequest
|
||||||
return self.request.user.role in ['MODERATOR', 'ADMIN', 'SUPERUSER']
|
|
||||||
|
|
||||||
class EditSubmissionListView(LoginRequiredMixin, ModeratorRequiredMixin, ListView):
|
def test_func(self) -> bool:
|
||||||
model = EditSubmission
|
"""Check if user has moderator permissions."""
|
||||||
template_name = 'moderation/edit_submissions.html'
|
user = cast(User, self.request.user)
|
||||||
|
return user.is_authenticated and (user.role in MODERATOR_ROLES or user.is_superuser)
|
||||||
|
|
||||||
|
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.")
|
||||||
|
|
||||||
|
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')
|
||||||
|
|
||||||
|
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(request: HttpRequest, queryset: QuerySet) -> Dict[str, Any]:
|
||||||
|
"""Get common context data for views."""
|
||||||
|
park_areas_by_park: Dict[int, List[Tuple[int, str]]] = {}
|
||||||
|
|
||||||
|
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'
|
context_object_name = 'submissions'
|
||||||
|
paginate_by = 10
|
||||||
def get_queryset(self):
|
|
||||||
tab = self.request.GET.get('tab', 'new')
|
def get_template_names(self) -> List[str]:
|
||||||
queryset = EditSubmission.objects.select_related('user', 'content_type')
|
if self.request.headers.get('HX-Request'):
|
||||||
|
return ['moderation/partials/dashboard_content.html']
|
||||||
# Include edits by privileged users (mods, admins, superusers) in appropriate tabs
|
|
||||||
privileged_roles = ['MODERATOR', 'ADMIN', 'SUPERUSER']
|
|
||||||
|
|
||||||
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')
|
|
||||||
|
|
||||||
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_template_names(self):
|
|
||||||
if self.request.htmx:
|
|
||||||
return ['moderation/partials/submission_list.html']
|
|
||||||
return [self.template_name]
|
return [self.template_name]
|
||||||
|
|
||||||
def approve_submission(request, submission_id):
|
def get_queryset(self) -> QuerySet:
|
||||||
submission = get_object_or_404(EditSubmission, id=submission_id)
|
status = self.request.GET.get('status', 'PENDING')
|
||||||
|
submission_type = self.request.GET.get('submission_type', '')
|
||||||
if request.user.role in ['MODERATOR', 'ADMIN', 'SUPERUSER']:
|
return get_filtered_queryset(self.request, status, submission_type)
|
||||||
submission.approve(request.user)
|
|
||||||
messages.success(request, 'Submission approved successfully')
|
|
||||||
|
|
||||||
# Return updated submission list for current tab
|
|
||||||
view = EditSubmissionListView.as_view()
|
|
||||||
return view(request)
|
|
||||||
|
|
||||||
def reject_submission(request, submission_id):
|
@login_required
|
||||||
submission = get_object_or_404(EditSubmission, id=submission_id)
|
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', '')
|
||||||
|
|
||||||
if request.user.role in ['MODERATOR', 'ADMIN', 'SUPERUSER']:
|
queryset = get_filtered_queryset(request, status, submission_type)
|
||||||
submission.reject(request.user)
|
|
||||||
messages.success(request, 'Submission rejected successfully')
|
|
||||||
|
|
||||||
# Return updated submission list for current tab
|
# Process location data for park submissions
|
||||||
view = EditSubmissionListView.as_view()
|
for submission in queryset:
|
||||||
return view(request)
|
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)
|
||||||
|
|
||||||
def escalate_submission(request, submission_id):
|
@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)
|
submission = get_object_or_404(EditSubmission, id=submission_id)
|
||||||
|
|
||||||
if request.user.role == 'MODERATOR':
|
if request.method != 'POST':
|
||||||
submission.escalate(request.user)
|
return HttpResponse("Invalid request method", status=405)
|
||||||
messages.success(request, 'Submission escalated to admin')
|
|
||||||
|
notes = request.POST.get('notes')
|
||||||
|
if not notes:
|
||||||
|
return HttpResponse("Notes are required when editing a submission", status=400)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
# Return updated submission list for current tab
|
if not ((submission.status != 'ESCALATED' and user.role in MODERATOR_ROLES) or
|
||||||
view = EditSubmissionListView.as_view()
|
user.role in ['ADMIN', 'SUPERUSER'] or user.is_superuser):
|
||||||
return view(request)
|
return HttpResponse("Insufficient permissions", status=403)
|
||||||
|
|
||||||
|
try:
|
||||||
|
submission.approve(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,
|
||||||
|
})
|
||||||
|
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 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)
|
||||||
|
|
||||||
|
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()
|
||||||
|
|||||||
245
parks/management/commands/seed_initial_data.py
Normal file
245
parks/management/commands/seed_initial_data.py
Normal 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'))
|
||||||
321
parks/management/commands/seed_ride_data.py
Normal file
321
parks/management/commands/seed_ride_data.py
Normal 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'))
|
||||||
@@ -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 django.db.models.deletion
|
||||||
|
import simple_history.models
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
@@ -7,57 +11,228 @@ class Migration(migrations.Migration):
|
|||||||
initial = True
|
initial = True
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
('companies', '0001_initial'),
|
("companies", "0001_initial"),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='Park',
|
name="HistoricalPark",
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
("id", models.BigIntegerField(blank=True, db_index=True)),
|
||||||
('name', models.CharField(max_length=255)),
|
("name", models.CharField(max_length=255)),
|
||||||
('slug', models.SlugField(max_length=255, unique=True)),
|
("slug", models.SlugField(max_length=255)),
|
||||||
('description', models.TextField(blank=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)),
|
"status",
|
||||||
('closing_date', models.DateField(blank=True, null=True)),
|
models.CharField(
|
||||||
('operating_season', models.CharField(blank=True, max_length=255)),
|
choices=[
|
||||||
('size_acres', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True)),
|
("OPERATING", "Operating"),
|
||||||
('website', models.URLField(blank=True)),
|
("CLOSED_TEMP", "Temporarily Closed"),
|
||||||
('average_rating', models.DecimalField(blank=True, decimal_places=2, max_digits=3, null=True)),
|
("CLOSED_PERM", "Permanently Closed"),
|
||||||
('total_rides', models.IntegerField(blank=True, null=True)),
|
("UNDER_CONSTRUCTION", "Under Construction"),
|
||||||
('total_roller_coasters', models.IntegerField(blank=True, null=True)),
|
("DEMOLISHED", "Demolished"),
|
||||||
('latitude', models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True)),
|
("RELOCATED", "Relocated"),
|
||||||
('longitude', models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True)),
|
],
|
||||||
('street_address', models.CharField(blank=True, max_length=255)),
|
default="OPERATING",
|
||||||
('city', models.CharField(blank=True, max_length=255)),
|
max_length=20,
|
||||||
('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)),
|
||||||
('created_at', models.DateTimeField(auto_now_add=True, null=True)),
|
("closing_date", models.DateField(blank=True, null=True)),
|
||||||
('updated_at', models.DateTimeField(auto_now=True)),
|
("operating_season", models.CharField(blank=True, max_length=255)),
|
||||||
('owner', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='parks', to='companies.company')),
|
(
|
||||||
|
"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={
|
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(
|
migrations.CreateModel(
|
||||||
name='ParkArea',
|
name="HistoricalParkArea",
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
("id", models.BigIntegerField(blank=True, db_index=True)),
|
||||||
('name', models.CharField(max_length=255)),
|
("name", models.CharField(max_length=255)),
|
||||||
('slug', models.SlugField(max_length=255)),
|
("slug", models.SlugField(max_length=255)),
|
||||||
('description', models.TextField(blank=True)),
|
("description", models.TextField(blank=True)),
|
||||||
('opening_date', models.DateField(blank=True, null=True)),
|
("opening_date", models.DateField(blank=True, null=True)),
|
||||||
('closing_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)),
|
"created_at",
|
||||||
('park', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='areas', to='parks.park')),
|
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={
|
options={
|
||||||
'ordering': ['name'],
|
"verbose_name": "historical park area",
|
||||||
'unique_together': {('park', 'slug')},
|
"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")},
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -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)',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -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,
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -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
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -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")),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -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",
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -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),
|
|
||||||
]
|
|
||||||
@@ -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",
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -4,14 +4,16 @@ from django.utils.text import slugify
|
|||||||
from django.contrib.contenttypes.fields import GenericRelation
|
from django.contrib.contenttypes.fields import GenericRelation
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from decimal import Decimal, ROUND_DOWN, InvalidOperation
|
from decimal import Decimal, ROUND_DOWN, InvalidOperation
|
||||||
from typing import Tuple, Optional, Any
|
from typing import Tuple, Optional, Any, TYPE_CHECKING
|
||||||
from simple_history.models import HistoricalRecords
|
|
||||||
|
|
||||||
from companies.models import Company
|
from companies.models import Company
|
||||||
from media.models import Photo
|
from media.models import Photo
|
||||||
from history_tracking.models import HistoricalModel
|
from history_tracking.models import HistoricalModel
|
||||||
from location.models import Location
|
from location.models import Location
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from rides.models import Ride
|
||||||
|
|
||||||
|
|
||||||
class Park(HistoricalModel):
|
class Park(HistoricalModel):
|
||||||
id: int # Type hint for Django's automatic id field
|
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"
|
Company, on_delete=models.SET_NULL, null=True, blank=True, related_name="parks"
|
||||||
)
|
)
|
||||||
photos = GenericRelation(Photo, related_query_name="park")
|
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
|
# Metadata
|
||||||
created_at = models.DateTimeField(auto_now_add=True, null=True)
|
created_at = models.DateTimeField(auto_now_add=True, null=True)
|
||||||
|
|||||||
@@ -8,13 +8,23 @@ urlpatterns = [
|
|||||||
# Park views
|
# Park views
|
||||||
path("", views.ParkListView.as_view(), name="park_list"),
|
path("", views.ParkListView.as_view(), name="park_list"),
|
||||||
path("create/", views.ParkCreateView.as_view(), name="park_create"),
|
path("create/", views.ParkCreateView.as_view(), name="park_create"),
|
||||||
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
|
# Location search endpoints
|
||||||
path("search/location/", views.location_search, name="location_search"),
|
path("search/location/", views.location_search, name="location_search"),
|
||||||
path("search/reverse-geocode/", views.reverse_geocode, name="reverse_geocode"),
|
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
|
# Area views
|
||||||
path("<slug:park_slug>/areas/<slug:area_slug>/", views.ParkAreaDetailView.as_view(), name="area_detail"),
|
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>/transports/", ParkSingleCategoryListView.as_view(), {'category': 'TR'}, name="park_transports"),
|
||||||
path("<slug:park_slug>/others/", ParkSingleCategoryListView.as_view(), {'category': 'OT'}, name="park_others"),
|
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")),
|
path("<slug:park_slug>/rides/", include("rides.urls", namespace="rides")),
|
||||||
]
|
]
|
||||||
|
|||||||
274
parks/views.py
274
parks/views.py
@@ -1,13 +1,15 @@
|
|||||||
from decimal import Decimal, ROUND_DOWN, InvalidOperation
|
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.views.generic import DetailView, ListView, CreateView, UpdateView
|
||||||
from django.shortcuts import get_object_or_404, render
|
from django.shortcuts import get_object_or_404, render
|
||||||
from django.core.serializers.json import DjangoJSONEncoder
|
from django.core.serializers.json import DjangoJSONEncoder
|
||||||
from django.urls import reverse
|
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.auth.mixins import LoginRequiredMixin
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.http import JsonResponse, HttpResponseRedirect, HttpResponse
|
from django.http import JsonResponse, HttpResponseRedirect, HttpResponse, HttpRequest
|
||||||
import requests
|
import requests
|
||||||
from .models import Park, ParkArea
|
from .models import Park, ParkArea
|
||||||
from .forms import ParkForm
|
from .forms import ParkForm
|
||||||
@@ -17,11 +19,49 @@ from moderation.mixins import EditSubmissionMixin, PhotoSubmissionMixin, History
|
|||||||
from moderation.models import EditSubmission
|
from moderation.models import EditSubmission
|
||||||
from media.models import Photo
|
from media.models import Photo
|
||||||
from location.models import Location
|
from location.models import Location
|
||||||
from reviews.models import Review # Import the Review model
|
from reviews.models import Review
|
||||||
from analytics.models import PageView # Import PageView for tracking views
|
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"""
|
"""Search for locations using OpenStreetMap Nominatim API"""
|
||||||
query = request.GET.get("q", "")
|
query = request.GET.get("q", "")
|
||||||
if not query:
|
if not query:
|
||||||
@@ -34,8 +74,8 @@ def location_search(request):
|
|||||||
"q": query,
|
"q": query,
|
||||||
"format": "json",
|
"format": "json",
|
||||||
"addressdetails": 1,
|
"addressdetails": 1,
|
||||||
"namedetails": 1, # Include name tags
|
"namedetails": 1,
|
||||||
"accept-language": "en", # Prefer English results
|
"accept-language": "en",
|
||||||
"limit": 10,
|
"limit": 10,
|
||||||
},
|
},
|
||||||
headers={"User-Agent": "ThrillWiki/1.0"},
|
headers={"User-Agent": "ThrillWiki/1.0"},
|
||||||
@@ -43,16 +83,18 @@ def location_search(request):
|
|||||||
|
|
||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
results = response.json()
|
results = response.json()
|
||||||
# Normalize each result
|
|
||||||
normalized_results = [normalize_osm_result(result) for result in results]
|
normalized_results = [normalize_osm_result(result) for result in results]
|
||||||
# Filter out any results with invalid coordinates
|
valid_results = [
|
||||||
valid_results = [r for r in normalized_results if r['lat'] is not None and r['lon'] is not None]
|
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": valid_results})
|
||||||
|
|
||||||
return JsonResponse({"results": []})
|
return JsonResponse({"results": []})
|
||||||
|
|
||||||
|
|
||||||
def reverse_geocode(request):
|
def reverse_geocode(request: HttpRequest) -> JsonResponse:
|
||||||
"""Reverse geocode coordinates using OpenStreetMap Nominatim API"""
|
"""Reverse geocode coordinates using OpenStreetMap Nominatim API"""
|
||||||
try:
|
try:
|
||||||
lat = Decimal(request.GET.get("lat", ""))
|
lat = Decimal(request.GET.get("lat", ""))
|
||||||
@@ -63,17 +105,18 @@ def reverse_geocode(request):
|
|||||||
if not lat or not lon:
|
if not lat or not lon:
|
||||||
return JsonResponse({"error": "Missing coordinates"}, status=400)
|
return JsonResponse({"error": "Missing coordinates"}, status=400)
|
||||||
|
|
||||||
# Normalize coordinates before geocoding
|
lat = lat.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)
|
||||||
lon = lon.quantize(Decimal('0.000001'), rounding=ROUND_DOWN)
|
|
||||||
|
|
||||||
# Validate ranges
|
|
||||||
if lat < -90 or lat > 90:
|
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:
|
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(
|
response = requests.get(
|
||||||
"https://nominatim.openstreetmap.org/reverse",
|
"https://nominatim.openstreetmap.org/reverse",
|
||||||
params={
|
params={
|
||||||
@@ -81,30 +124,36 @@ def reverse_geocode(request):
|
|||||||
"lon": str(lon),
|
"lon": str(lon),
|
||||||
"format": "json",
|
"format": "json",
|
||||||
"addressdetails": 1,
|
"addressdetails": 1,
|
||||||
"namedetails": 1, # Include name tags
|
"namedetails": 1,
|
||||||
"accept-language": "en", # Prefer English results
|
"accept-language": "en",
|
||||||
},
|
},
|
||||||
headers={"User-Agent": "ThrillWiki/1.0"},
|
headers={"User-Agent": "ThrillWiki/1.0"},
|
||||||
)
|
)
|
||||||
|
|
||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
result = response.json()
|
result = response.json()
|
||||||
# Normalize the result
|
|
||||||
normalized_result = normalize_osm_result(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({"error": "Invalid coordinates"}, status=400)
|
||||||
return JsonResponse(normalized_result)
|
return JsonResponse(normalized_result)
|
||||||
|
|
||||||
return JsonResponse({"error": "Geocoding failed"}, status=500)
|
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):
|
class ParkListView(ListView):
|
||||||
model = Park
|
model = Park
|
||||||
template_name = "parks/park_list.html"
|
template_name = "parks/park_list.html"
|
||||||
context_object_name = "parks"
|
context_object_name = "parks"
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self) -> QuerySet[Park]:
|
||||||
queryset = Park.objects.select_related("owner").prefetch_related("photos", "location")
|
queryset = Park.objects.select_related("owner").prefetch_related(
|
||||||
|
"photos", "location"
|
||||||
|
)
|
||||||
|
|
||||||
search = self.request.GET.get("search", "").strip()
|
search = self.request.GET.get("search", "").strip()
|
||||||
country = self.request.GET.get("country", "").strip()
|
country = self.request.GET.get("country", "").strip()
|
||||||
@@ -114,10 +163,10 @@ class ParkListView(ListView):
|
|||||||
|
|
||||||
if search:
|
if search:
|
||||||
queryset = queryset.filter(
|
queryset = queryset.filter(
|
||||||
Q(name__icontains=search) |
|
Q(name__icontains=search)
|
||||||
Q(location__city__icontains=search) |
|
| Q(location__city__icontains=search)
|
||||||
Q(location__state__icontains=search) |
|
| Q(location__state__icontains=search)
|
||||||
Q(location__country__icontains=search)
|
| Q(location__country__icontains=search)
|
||||||
)
|
)
|
||||||
|
|
||||||
if country:
|
if country:
|
||||||
@@ -132,16 +181,14 @@ class ParkListView(ListView):
|
|||||||
if statuses:
|
if statuses:
|
||||||
queryset = queryset.filter(status__in=statuses)
|
queryset = queryset.filter(status__in=statuses)
|
||||||
|
|
||||||
# Annotate with ride count, coaster count, and average review rating
|
|
||||||
queryset = queryset.annotate(
|
queryset = queryset.annotate(
|
||||||
ride_count=Count('rides'),
|
total_rides=Count("rides"),
|
||||||
coaster_count=Count('rides', filter=Q(rides__type='coaster')),
|
total_coasters=Count("rides", filter=Q(rides__category="RC")),
|
||||||
average_rating=Avg('reviews__rating')
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return queryset.distinct()
|
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 = super().get_context_data(**kwargs)
|
||||||
context["current_filters"] = {
|
context["current_filters"] = {
|
||||||
"search": self.request.GET.get("search", ""),
|
"search": self.request.GET.get("search", ""),
|
||||||
@@ -152,10 +199,8 @@ class ParkListView(ListView):
|
|||||||
}
|
}
|
||||||
return context
|
return context
|
||||||
|
|
||||||
def get(self, request, *args, **kwargs):
|
def get(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
|
||||||
# Check if this is an HTMX request
|
if hasattr(request, "htmx") and getattr(request, "htmx", False):
|
||||||
if hasattr(request, 'htmx') and getattr(request, 'htmx', False):
|
|
||||||
# If it is, return just the parks list partial
|
|
||||||
self.template_name = "parks/partials/park_list.html"
|
self.template_name = "parks/partials/park_list.html"
|
||||||
return super().get(request, *args, **kwargs)
|
return super().get(request, *args, **kwargs)
|
||||||
|
|
||||||
@@ -171,44 +216,43 @@ class ParkDetailView(
|
|||||||
template_name = "parks/park_detail.html"
|
template_name = "parks/park_detail.html"
|
||||||
context_object_name = "park"
|
context_object_name = "park"
|
||||||
|
|
||||||
def get_object(self, queryset=None):
|
def get_object(self, queryset: Optional[QuerySet[Park]] = None) -> Park:
|
||||||
if queryset is None:
|
if queryset is None:
|
||||||
queryset = self.get_queryset()
|
queryset = self.get_queryset()
|
||||||
slug = self.kwargs.get(self.slug_url_kwarg)
|
slug = self.kwargs.get(self.slug_url_kwarg)
|
||||||
# Try to get by current or historical slug
|
if slug is None:
|
||||||
return Park.get_by_slug(slug)[0]
|
raise ObjectDoesNotExist("No slug provided")
|
||||||
|
park, _ = Park.get_by_slug(slug)
|
||||||
|
return park
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self) -> QuerySet[Park]:
|
||||||
return super().get_queryset().prefetch_related(
|
return cast(
|
||||||
'rides',
|
QuerySet[Park],
|
||||||
'rides__manufacturer',
|
super()
|
||||||
'photos',
|
.get_queryset()
|
||||||
'areas',
|
.prefetch_related(
|
||||||
'location'
|
"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 = super().get_context_data(**kwargs)
|
||||||
context["areas"] = self.object.areas.all()
|
park = cast(Park, self.object)
|
||||||
# Get rides ordered by status (operating first) and name
|
context["areas"] = park.areas.all()
|
||||||
context["rides"] = self.object.rides.all().order_by(
|
context["rides"] = park.rides.all().order_by("-status", "name")
|
||||||
'-status', # OPERATING will come before others
|
|
||||||
'name'
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check if the user has reviewed the park
|
|
||||||
if self.request.user.is_authenticated:
|
if self.request.user.is_authenticated:
|
||||||
context["has_reviewed"] = Review.objects.filter(
|
context["has_reviewed"] = Review.objects.filter(
|
||||||
user=self.request.user,
|
user=self.request.user,
|
||||||
content_type=ContentType.objects.get_for_model(Park),
|
content_type=ContentType.objects.get_for_model(Park),
|
||||||
object_id=self.object.id
|
object_id=park.id,
|
||||||
).exists()
|
).exists()
|
||||||
else:
|
else:
|
||||||
context["has_reviewed"] = False
|
context["has_reviewed"] = False
|
||||||
|
|
||||||
return context
|
return context
|
||||||
|
|
||||||
def get_redirect_url_pattern(self):
|
def get_redirect_url_pattern(self) -> str:
|
||||||
return "parks:park_detail"
|
return "parks:park_detail"
|
||||||
|
|
||||||
|
|
||||||
@@ -217,38 +261,36 @@ class ParkCreateView(LoginRequiredMixin, CreateView):
|
|||||||
form_class = ParkForm
|
form_class = ParkForm
|
||||||
template_name = "parks/park_form.html"
|
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()
|
data = cleaned_data.copy()
|
||||||
# Convert model instances to IDs for JSON serialization
|
|
||||||
if data.get("owner"):
|
if data.get("owner"):
|
||||||
data["owner"] = data["owner"].id
|
data["owner"] = data["owner"].id
|
||||||
# Convert dates to ISO format strings
|
|
||||||
if data.get("opening_date"):
|
if data.get("opening_date"):
|
||||||
data["opening_date"] = data["opening_date"].isoformat()
|
data["opening_date"] = data["opening_date"].isoformat()
|
||||||
if data.get("closing_date"):
|
if data.get("closing_date"):
|
||||||
data["closing_date"] = data["closing_date"].isoformat()
|
data["closing_date"] = data["closing_date"].isoformat()
|
||||||
# Convert Decimal fields to strings
|
|
||||||
decimal_fields = ["latitude", "longitude", "size_acres", "average_rating"]
|
decimal_fields = ["latitude", "longitude", "size_acres", "average_rating"]
|
||||||
for field in decimal_fields:
|
for field in decimal_fields:
|
||||||
if data.get(field):
|
if data.get(field):
|
||||||
data[field] = str(data[field])
|
data[field] = str(data[field])
|
||||||
return data
|
return data
|
||||||
|
|
||||||
def normalize_coordinates(self, form):
|
def normalize_coordinates(self, form: ParkForm) -> None:
|
||||||
if form.cleaned_data.get("latitude"):
|
if form.cleaned_data.get("latitude"):
|
||||||
lat = Decimal(str(form.cleaned_data["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"):
|
if form.cleaned_data.get("longitude"):
|
||||||
lon = Decimal(str(form.cleaned_data["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):
|
def form_valid(self, form: ParkForm) -> HttpResponse:
|
||||||
# Normalize coordinates before saving
|
|
||||||
self.normalize_coordinates(form)
|
self.normalize_coordinates(form)
|
||||||
|
|
||||||
changes = self.prepare_changes_data(form.cleaned_data)
|
changes = self.prepare_changes_data(form.cleaned_data)
|
||||||
|
|
||||||
# Create submission record
|
|
||||||
submission = EditSubmission.objects.create(
|
submission = EditSubmission.objects.create(
|
||||||
user=self.request.user,
|
user=self.request.user,
|
||||||
content_type=ContentType.objects.get_for_model(Park),
|
content_type=ContentType.objects.get_for_model(Park),
|
||||||
@@ -258,8 +300,9 @@ class ParkCreateView(LoginRequiredMixin, CreateView):
|
|||||||
source=self.request.POST.get("source", ""),
|
source=self.request.POST.get("source", ""),
|
||||||
)
|
)
|
||||||
|
|
||||||
# If user is moderator or above, auto-approve
|
if hasattr(self.request.user, "role") and getattr(
|
||||||
if hasattr(self.request.user, 'role') and getattr(self.request.user, 'role', None) in ["MODERATOR", "ADMIN", "SUPERUSER"]:
|
self.request.user, "role", None
|
||||||
|
) in ["MODERATOR", "ADMIN", "SUPERUSER"]:
|
||||||
try:
|
try:
|
||||||
self.object = form.save()
|
self.object = form.save()
|
||||||
submission.object_id = self.object.id
|
submission.object_id = self.object.id
|
||||||
@@ -267,23 +310,23 @@ class ParkCreateView(LoginRequiredMixin, CreateView):
|
|||||||
submission.handled_by = self.request.user
|
submission.handled_by = self.request.user
|
||||||
submission.save()
|
submission.save()
|
||||||
|
|
||||||
# Create Location record
|
if form.cleaned_data.get("latitude") and form.cleaned_data.get(
|
||||||
if form.cleaned_data.get("latitude") and form.cleaned_data.get("longitude"):
|
"longitude"
|
||||||
|
):
|
||||||
Location.objects.create(
|
Location.objects.create(
|
||||||
content_type=ContentType.objects.get_for_model(Park),
|
content_type=ContentType.objects.get_for_model(Park),
|
||||||
object_id=self.object.id,
|
object_id=self.object.id,
|
||||||
name=self.object.name,
|
name=self.object.name,
|
||||||
location_type='park',
|
location_type="park",
|
||||||
latitude=form.cleaned_data["latitude"],
|
latitude=form.cleaned_data["latitude"],
|
||||||
longitude=form.cleaned_data["longitude"],
|
longitude=form.cleaned_data["longitude"],
|
||||||
street_address=form.cleaned_data.get("street_address", ""),
|
street_address=form.cleaned_data.get("street_address", ""),
|
||||||
city=form.cleaned_data.get("city", ""),
|
city=form.cleaned_data.get("city", ""),
|
||||||
state=form.cleaned_data.get("state", ""),
|
state=form.cleaned_data.get("state", ""),
|
||||||
country=form.cleaned_data.get("country", ""),
|
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")
|
photos = self.request.FILES.getlist("photos")
|
||||||
for photo_file in photos:
|
for photo_file in photos:
|
||||||
try:
|
try:
|
||||||
@@ -319,7 +362,7 @@ class ParkCreateView(LoginRequiredMixin, CreateView):
|
|||||||
)
|
)
|
||||||
return HttpResponseRedirect(reverse("parks:park_list"))
|
return HttpResponseRedirect(reverse("parks:park_list"))
|
||||||
|
|
||||||
def form_invalid(self, form):
|
def form_invalid(self, form: ParkForm) -> HttpResponse:
|
||||||
messages.error(
|
messages.error(
|
||||||
self.request,
|
self.request,
|
||||||
"Please correct the errors below. Required fields are marked with an asterisk (*).",
|
"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}")
|
messages.error(self.request, f"{field}: {error}")
|
||||||
return super().form_invalid(form)
|
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})
|
return reverse("parks:park_detail", kwargs={"slug": self.object.slug})
|
||||||
|
|
||||||
|
|
||||||
@@ -338,43 +381,41 @@ class ParkUpdateView(LoginRequiredMixin, UpdateView):
|
|||||||
form_class = ParkForm
|
form_class = ParkForm
|
||||||
template_name = "parks/park_form.html"
|
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 = super().get_context_data(**kwargs)
|
||||||
context["is_edit"] = True
|
context["is_edit"] = True
|
||||||
return context
|
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()
|
data = cleaned_data.copy()
|
||||||
# Convert model instances to IDs for JSON serialization
|
|
||||||
if data.get("owner"):
|
if data.get("owner"):
|
||||||
data["owner"] = data["owner"].id
|
data["owner"] = data["owner"].id
|
||||||
# Convert dates to ISO format strings
|
|
||||||
if data.get("opening_date"):
|
if data.get("opening_date"):
|
||||||
data["opening_date"] = data["opening_date"].isoformat()
|
data["opening_date"] = data["opening_date"].isoformat()
|
||||||
if data.get("closing_date"):
|
if data.get("closing_date"):
|
||||||
data["closing_date"] = data["closing_date"].isoformat()
|
data["closing_date"] = data["closing_date"].isoformat()
|
||||||
# Convert Decimal fields to strings
|
|
||||||
decimal_fields = ["latitude", "longitude", "size_acres", "average_rating"]
|
decimal_fields = ["latitude", "longitude", "size_acres", "average_rating"]
|
||||||
for field in decimal_fields:
|
for field in decimal_fields:
|
||||||
if data.get(field):
|
if data.get(field):
|
||||||
data[field] = str(data[field])
|
data[field] = str(data[field])
|
||||||
return data
|
return data
|
||||||
|
|
||||||
def normalize_coordinates(self, form):
|
def normalize_coordinates(self, form: ParkForm) -> None:
|
||||||
if form.cleaned_data.get("latitude"):
|
if form.cleaned_data.get("latitude"):
|
||||||
lat = Decimal(str(form.cleaned_data["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"):
|
if form.cleaned_data.get("longitude"):
|
||||||
lon = Decimal(str(form.cleaned_data["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):
|
def form_valid(self, form: ParkForm) -> HttpResponse:
|
||||||
# Normalize coordinates before saving
|
|
||||||
self.normalize_coordinates(form)
|
self.normalize_coordinates(form)
|
||||||
|
|
||||||
changes = self.prepare_changes_data(form.cleaned_data)
|
changes = self.prepare_changes_data(form.cleaned_data)
|
||||||
|
|
||||||
# Create submission record
|
|
||||||
submission = EditSubmission.objects.create(
|
submission = EditSubmission.objects.create(
|
||||||
user=self.request.user,
|
user=self.request.user,
|
||||||
content_type=ContentType.objects.get_for_model(Park),
|
content_type=ContentType.objects.get_for_model(Park),
|
||||||
@@ -385,25 +426,25 @@ class ParkUpdateView(LoginRequiredMixin, UpdateView):
|
|||||||
source=self.request.POST.get("source", ""),
|
source=self.request.POST.get("source", ""),
|
||||||
)
|
)
|
||||||
|
|
||||||
# If user is moderator or above, auto-approve
|
if hasattr(self.request.user, "role") and getattr(
|
||||||
if hasattr(self.request.user, 'role') and getattr(self.request.user, 'role', None) in ["MODERATOR", "ADMIN", "SUPERUSER"]:
|
self.request.user, "role", None
|
||||||
|
) in ["MODERATOR", "ADMIN", "SUPERUSER"]:
|
||||||
try:
|
try:
|
||||||
self.object = form.save()
|
self.object = form.save()
|
||||||
submission.status = "APPROVED"
|
submission.status = "APPROVED"
|
||||||
submission.handled_by = self.request.user
|
submission.handled_by = self.request.user
|
||||||
submission.save()
|
submission.save()
|
||||||
|
|
||||||
# Update or create Location record
|
|
||||||
location_data = {
|
location_data = {
|
||||||
'name': self.object.name,
|
"name": self.object.name,
|
||||||
'location_type': 'park',
|
"location_type": "park",
|
||||||
'latitude': form.cleaned_data.get("latitude"),
|
"latitude": form.cleaned_data.get("latitude"),
|
||||||
'longitude': form.cleaned_data.get("longitude"),
|
"longitude": form.cleaned_data.get("longitude"),
|
||||||
'street_address': form.cleaned_data.get("street_address", ""),
|
"street_address": form.cleaned_data.get("street_address", ""),
|
||||||
'city': form.cleaned_data.get("city", ""),
|
"city": form.cleaned_data.get("city", ""),
|
||||||
'state': form.cleaned_data.get("state", ""),
|
"state": form.cleaned_data.get("state", ""),
|
||||||
'country': form.cleaned_data.get("country", ""),
|
"country": form.cleaned_data.get("country", ""),
|
||||||
'postal_code': form.cleaned_data.get("postal_code", "")
|
"postal_code": form.cleaned_data.get("postal_code", ""),
|
||||||
}
|
}
|
||||||
|
|
||||||
if self.object.location.exists():
|
if self.object.location.exists():
|
||||||
@@ -415,10 +456,9 @@ class ParkUpdateView(LoginRequiredMixin, UpdateView):
|
|||||||
Location.objects.create(
|
Location.objects.create(
|
||||||
content_type=ContentType.objects.get_for_model(Park),
|
content_type=ContentType.objects.get_for_model(Park),
|
||||||
object_id=self.object.id,
|
object_id=self.object.id,
|
||||||
**location_data
|
**location_data,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Handle photo uploads
|
|
||||||
photos = self.request.FILES.getlist("photos")
|
photos = self.request.FILES.getlist("photos")
|
||||||
uploaded_count = 0
|
uploaded_count = 0
|
||||||
for photo_file in photos:
|
for photo_file in photos:
|
||||||
@@ -458,7 +498,7 @@ class ParkUpdateView(LoginRequiredMixin, UpdateView):
|
|||||||
reverse("parks:park_detail", kwargs={"slug": self.object.slug})
|
reverse("parks:park_detail", kwargs={"slug": self.object.slug})
|
||||||
)
|
)
|
||||||
|
|
||||||
def form_invalid(self, form):
|
def form_invalid(self, form: ParkForm) -> HttpResponse:
|
||||||
messages.error(
|
messages.error(
|
||||||
self.request,
|
self.request,
|
||||||
"Please correct the errors below. Required fields are marked with an asterisk (*).",
|
"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}")
|
messages.error(self.request, f"{field}: {error}")
|
||||||
return super().form_invalid(form)
|
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})
|
return reverse("parks:park_detail", kwargs={"slug": self.object.slug})
|
||||||
|
|
||||||
|
|
||||||
@@ -484,23 +524,25 @@ class ParkAreaDetailView(
|
|||||||
context_object_name = "area"
|
context_object_name = "area"
|
||||||
slug_url_kwarg = "area_slug"
|
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:
|
if queryset is None:
|
||||||
queryset = self.get_queryset()
|
queryset = self.get_queryset()
|
||||||
park_slug = self.kwargs.get("park_slug")
|
park_slug = self.kwargs.get("park_slug")
|
||||||
area_slug = self.kwargs.get("area_slug")
|
area_slug = self.kwargs.get("area_slug")
|
||||||
# Try to get by current or historical slug
|
if park_slug is None or area_slug is None:
|
||||||
obj, is_old_slug = ParkArea.get_by_slug(area_slug)
|
raise ObjectDoesNotExist("Missing slug")
|
||||||
if obj.park.slug != park_slug:
|
area, _ = ParkArea.get_by_slug(area_slug)
|
||||||
raise self.model.DoesNotExist("Park slug doesn't match")
|
if area.park.slug != park_slug:
|
||||||
return obj
|
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)
|
context = super().get_context_data(**kwargs)
|
||||||
return context
|
return context
|
||||||
|
|
||||||
def get_redirect_url_pattern(self):
|
def get_redirect_url_pattern(self) -> str:
|
||||||
return "parks:park_detail"
|
return "parks:park_detail"
|
||||||
|
|
||||||
def get_redirect_url_kwargs(self):
|
def get_redirect_url_kwargs(self) -> dict[str, str]:
|
||||||
return {"park_slug": self.object.park.slug, "area_slug": self.object.slug}
|
area = cast(ParkArea, self.object)
|
||||||
|
return {"park_slug": area.park.slug, "area_slug": area.slug}
|
||||||
|
|||||||
@@ -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.core.validators
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
@@ -11,78 +11,187 @@ class Migration(migrations.Migration):
|
|||||||
initial = True
|
initial = True
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
('contenttypes', '0002_remove_content_type_name'),
|
("contenttypes", "0002_remove_content_type_name"),
|
||||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='Review',
|
name="Review",
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
(
|
||||||
('object_id', models.PositiveIntegerField()),
|
"id",
|
||||||
('rating', models.PositiveSmallIntegerField(validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(10)])),
|
models.BigAutoField(
|
||||||
('title', models.CharField(max_length=200)),
|
auto_created=True,
|
||||||
('content', models.TextField()),
|
primary_key=True,
|
||||||
('visit_date', models.DateField()),
|
serialize=False,
|
||||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
verbose_name="ID",
|
||||||
('updated_at', models.DateTimeField(auto_now=True)),
|
),
|
||||||
('is_published', models.BooleanField(default=True)),
|
),
|
||||||
('moderation_notes', models.TextField(blank=True)),
|
("object_id", models.PositiveIntegerField()),
|
||||||
('moderated_at', models.DateTimeField(blank=True, null=True)),
|
(
|
||||||
('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')),
|
"rating",
|
||||||
('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)),
|
models.PositiveSmallIntegerField(
|
||||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reviews', to=settings.AUTH_USER_MODEL)),
|
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={
|
options={
|
||||||
'ordering': ['-created_at'],
|
"ordering": ["-created_at"],
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='ReviewImage',
|
name="ReviewImage",
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
(
|
||||||
('image', models.ImageField(upload_to='review_images/')),
|
"id",
|
||||||
('caption', models.CharField(blank=True, max_length=200)),
|
models.BigAutoField(
|
||||||
('order', models.PositiveIntegerField(default=0)),
|
auto_created=True,
|
||||||
('review', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='images', to='reviews.review')),
|
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={
|
options={
|
||||||
'ordering': ['order'],
|
"ordering": ["order"],
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='ReviewLike',
|
name="ReviewLike",
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
(
|
||||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
"id",
|
||||||
('review', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='likes', to='reviews.review')),
|
models.BigAutoField(
|
||||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='review_likes', to=settings.AUTH_USER_MODEL)),
|
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(
|
migrations.CreateModel(
|
||||||
name='ReviewReport',
|
name="ReviewReport",
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
(
|
||||||
('reason', models.TextField()),
|
"id",
|
||||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
models.BigAutoField(
|
||||||
('resolved', models.BooleanField(default=False)),
|
auto_created=True,
|
||||||
('resolution_notes', models.TextField(blank=True)),
|
primary_key=True,
|
||||||
('resolved_at', models.DateTimeField(blank=True, null=True)),
|
serialize=False,
|
||||||
('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)),
|
verbose_name="ID",
|
||||||
('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)),
|
),
|
||||||
|
("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={
|
options={
|
||||||
'ordering': ['-created_at'],
|
"ordering": ["-created_at"],
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
migrations.AddIndex(
|
migrations.AddIndex(
|
||||||
model_name='review',
|
model_name="review",
|
||||||
index=models.Index(fields=['content_type', 'object_id'], name='reviews_rev_content_627d80_idx'),
|
index=models.Index(
|
||||||
|
fields=["content_type", "object_id"],
|
||||||
|
name="reviews_rev_content_627d80_idx",
|
||||||
|
),
|
||||||
),
|
),
|
||||||
migrations.AlterUniqueTogether(
|
migrations.AlterUniqueTogether(
|
||||||
name='reviewlike',
|
name="reviewlike",
|
||||||
unique_together={('review', 'user')},
|
unique_together={("review", "user")},
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
0
reviews/templatetags/__init__.py
Normal file
0
reviews/templatetags/__init__.py
Normal file
11
reviews/templatetags/review_tags.py
Normal file
11
reviews/templatetags/review_tags.py
Normal 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()
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
from django.apps import AppConfig
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
class RidesConfig(AppConfig):
|
class RidesConfig(AppConfig):
|
||||||
default_auto_field = 'django.db.models.BigAutoField'
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
name = 'rides'
|
name = 'rides'
|
||||||
|
|
||||||
def ready(self):
|
def ready(self):
|
||||||
import rides.signals # noqa
|
import rides.signals
|
||||||
|
|||||||
335
rides/forms.py
335
rides/forms.py
@@ -1,74 +1,283 @@
|
|||||||
from django import forms
|
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):
|
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:
|
class Meta:
|
||||||
model = Ride
|
model = Ride
|
||||||
fields = ['name', 'park_area', 'category', 'manufacturer', 'designer', 'model_name', 'status',
|
fields = [
|
||||||
'opening_date', 'closing_date', 'status_since', 'min_height_in', 'max_height_in',
|
"name",
|
||||||
'accessibility_options', 'capacity_per_hour', 'ride_duration_seconds', 'description']
|
"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 = {
|
widgets = {
|
||||||
'name': forms.TextInput(attrs={
|
"name": forms.TextInput(
|
||||||
'class': 'w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white'
|
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={
|
"placeholder": "Official name of the ride"
|
||||||
'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={
|
"category": forms.Select(
|
||||||
'class': 'w-full border-gray-300 rounded-lg form-select dark:border-gray-600 dark:bg-gray-700 dark:text-white'
|
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={
|
"hx-get": reverse_lazy("rides:coaster_fields"),
|
||||||
'class': 'w-full border-gray-300 rounded-lg form-select dark:border-gray-600 dark:bg-gray-700 dark:text-white'
|
"hx-target": "#coaster-fields",
|
||||||
}),
|
"hx-trigger": "change",
|
||||||
'designer': forms.Select(attrs={
|
"hx-include": "this",
|
||||||
'class': 'w-full border-gray-300 rounded-lg form-select dark:border-gray-600 dark:bg-gray-700 dark:text-white'
|
"hx-swap": "innerHTML"
|
||||||
}),
|
}
|
||||||
'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={
|
||||||
'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",
|
||||||
'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",
|
||||||
'opening_date': forms.DateInput(attrs={
|
"@change": "handleStatusChange"
|
||||||
'type': 'date',
|
}
|
||||||
'class': 'w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white'
|
),
|
||||||
}),
|
"post_closing_status": forms.Select(
|
||||||
'closing_date': forms.DateInput(attrs={
|
attrs={
|
||||||
'type': 'date',
|
"class": "w-full border-gray-300 rounded-lg form-select dark:border-gray-600 dark:bg-gray-700 dark:text-white",
|
||||||
'class': 'w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white'
|
"placeholder": "Status after closing",
|
||||||
}),
|
"x-show": "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'
|
"opening_date": forms.DateInput(
|
||||||
}),
|
attrs={
|
||||||
'min_height_in': forms.NumberInput(attrs={
|
"type": "date",
|
||||||
'class': 'w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white',
|
"class": "w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white",
|
||||||
'min': '0'
|
"placeholder": "Date when ride first opened"
|
||||||
}),
|
}
|
||||||
'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',
|
"closing_date": forms.DateInput(
|
||||||
'min': '0'
|
attrs={
|
||||||
}),
|
"type": "date",
|
||||||
'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",
|
||||||
'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)",
|
||||||
'capacity_per_hour': forms.NumberInput(attrs={
|
":required": "status === 'CLOSING'"
|
||||||
'class': 'w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white',
|
}
|
||||||
'min': '0'
|
),
|
||||||
}),
|
"status_since": forms.DateInput(
|
||||||
'ride_duration_seconds': forms.NumberInput(attrs={
|
attrs={
|
||||||
'class': 'w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white',
|
"type": "date",
|
||||||
'min': '0'
|
"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"
|
||||||
'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'
|
"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):
|
def __init__(self, *args, **kwargs):
|
||||||
park = kwargs.pop('park', None)
|
park = kwargs.pop("park", None)
|
||||||
super().__init__(*args, **kwargs)
|
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:
|
if park:
|
||||||
# Filter park_area choices to only show areas from the current park
|
# If park is provided, set it as the initial value
|
||||||
self.fields['park_area'].queryset = park.areas.all()
|
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
|
||||||
|
|||||||
@@ -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 django.db.models.deletion
|
||||||
import simple_history.models
|
import simple_history.models
|
||||||
@@ -11,7 +11,8 @@ class Migration(migrations.Migration):
|
|||||||
initial = True
|
initial = True
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("companies", "0002_stats_fields"),
|
("companies", "0001_initial"),
|
||||||
|
("designers", "0001_initial"),
|
||||||
("parks", "0001_initial"),
|
("parks", "0001_initial"),
|
||||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
]
|
]
|
||||||
@@ -20,12 +21,7 @@ class Migration(migrations.Migration):
|
|||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name="HistoricalRide",
|
name="HistoricalRide",
|
||||||
fields=[
|
fields=[
|
||||||
(
|
("id", models.BigIntegerField(blank=True, db_index=True)),
|
||||||
"id",
|
|
||||||
models.BigIntegerField(
|
|
||||||
auto_created=True, blank=True, db_index=True, verbose_name="ID"
|
|
||||||
),
|
|
||||||
),
|
|
||||||
("name", models.CharField(max_length=255)),
|
("name", models.CharField(max_length=255)),
|
||||||
("slug", models.SlugField(max_length=255)),
|
("slug", models.SlugField(max_length=255)),
|
||||||
("description", models.TextField(blank=True)),
|
("description", models.TextField(blank=True)),
|
||||||
@@ -92,6 +88,18 @@ class Migration(migrations.Migration):
|
|||||||
max_length=1,
|
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",
|
"history_user",
|
||||||
models.ForeignKey(
|
models.ForeignKey(
|
||||||
@@ -146,15 +154,7 @@ class Migration(migrations.Migration):
|
|||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name="Ride",
|
name="Ride",
|
||||||
fields=[
|
fields=[
|
||||||
(
|
("id", models.BigAutoField(primary_key=True, serialize=False)),
|
||||||
"id",
|
|
||||||
models.BigAutoField(
|
|
||||||
auto_created=True,
|
|
||||||
primary_key=True,
|
|
||||||
serialize=False,
|
|
||||||
verbose_name="ID",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
("name", models.CharField(max_length=255)),
|
("name", models.CharField(max_length=255)),
|
||||||
("slug", models.SlugField(max_length=255)),
|
("slug", models.SlugField(max_length=255)),
|
||||||
("description", models.TextField(blank=True)),
|
("description", models.TextField(blank=True)),
|
||||||
@@ -212,12 +212,20 @@ class Migration(migrations.Migration):
|
|||||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
("updated_at", models.DateTimeField(auto_now=True)),
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
(
|
(
|
||||||
"manufacturer",
|
"designer",
|
||||||
models.ForeignKey(
|
models.ForeignKey(
|
||||||
blank=True,
|
blank=True,
|
||||||
|
help_text="The designer/engineering firm responsible for the ride",
|
||||||
null=True,
|
null=True,
|
||||||
on_delete=django.db.models.deletion.SET_NULL,
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
related_name="rides",
|
related_name="rides",
|
||||||
|
to="designers.designer",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"manufacturer",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
to="companies.manufacturer",
|
to="companies.manufacturer",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -248,12 +256,7 @@ class Migration(migrations.Migration):
|
|||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name="HistoricalRollerCoasterStats",
|
name="HistoricalRollerCoasterStats",
|
||||||
fields=[
|
fields=[
|
||||||
(
|
("id", models.BigIntegerField(blank=True, db_index=True)),
|
||||||
"id",
|
|
||||||
models.BigIntegerField(
|
|
||||||
auto_created=True, blank=True, db_index=True, verbose_name="ID"
|
|
||||||
),
|
|
||||||
),
|
|
||||||
(
|
(
|
||||||
"height_ft",
|
"height_ft",
|
||||||
models.DecimalField(
|
models.DecimalField(
|
||||||
@@ -278,6 +281,57 @@ class Migration(migrations.Migration):
|
|||||||
models.PositiveIntegerField(blank=True, null=True),
|
models.PositiveIntegerField(blank=True, null=True),
|
||||||
),
|
),
|
||||||
("track_type", models.CharField(blank=True, max_length=255)),
|
("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",
|
"launch_type",
|
||||||
models.CharField(
|
models.CharField(
|
||||||
@@ -340,15 +394,7 @@ class Migration(migrations.Migration):
|
|||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name="RollerCoasterStats",
|
name="RollerCoasterStats",
|
||||||
fields=[
|
fields=[
|
||||||
(
|
("id", models.BigAutoField(primary_key=True, serialize=False)),
|
||||||
"id",
|
|
||||||
models.BigAutoField(
|
|
||||||
auto_created=True,
|
|
||||||
primary_key=True,
|
|
||||||
serialize=False,
|
|
||||||
verbose_name="ID",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
(
|
(
|
||||||
"height_ft",
|
"height_ft",
|
||||||
models.DecimalField(
|
models.DecimalField(
|
||||||
@@ -373,6 +419,57 @@ class Migration(migrations.Migration):
|
|||||||
models.PositiveIntegerField(blank=True, null=True),
|
models.PositiveIntegerField(blank=True, null=True),
|
||||||
),
|
),
|
||||||
("track_type", models.CharField(blank=True, max_length=255)),
|
("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",
|
"launch_type",
|
||||||
models.CharField(
|
models.CharField(
|
||||||
|
|||||||
@@ -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
|
import django.db.models.deletion
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
@@ -7,12 +7,12 @@ from django.db import migrations, models
|
|||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("designers", "0001_initial"),
|
("companies", "0002_add_designer_model"),
|
||||||
("rides", "0004_historicalrollercoasterstats_roller_coaster_type_and_more"),
|
("rides", "0001_initial"),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.AddField(
|
migrations.AlterField(
|
||||||
model_name="historicalride",
|
model_name="historicalride",
|
||||||
name="designer",
|
name="designer",
|
||||||
field=models.ForeignKey(
|
field=models.ForeignKey(
|
||||||
@@ -22,10 +22,10 @@ class Migration(migrations.Migration):
|
|||||||
null=True,
|
null=True,
|
||||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||||
related_name="+",
|
related_name="+",
|
||||||
to="designers.designer",
|
to="companies.designer",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
migrations.AddField(
|
migrations.AlterField(
|
||||||
model_name="ride",
|
model_name="ride",
|
||||||
name="designer",
|
name="designer",
|
||||||
field=models.ForeignKey(
|
field=models.ForeignKey(
|
||||||
@@ -34,7 +34,7 @@ class Migration(migrations.Migration):
|
|||||||
null=True,
|
null=True,
|
||||||
on_delete=django.db.models.deletion.SET_NULL,
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
related_name="rides",
|
related_name="rides",
|
||||||
to="designers.designer",
|
to="companies.designer",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
@@ -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,
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
259
rides/migrations/0005_alter_rollercoasterstats_id_and_more.py
Normal file
259
rides/migrations/0005_alter_rollercoasterstats_id_and_more.py
Normal 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",
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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",
|
||||||
|
),
|
||||||
|
]
|
||||||
26
rides/migrations/0007_alter_ridemodel_manufacturer.py
Normal file
26
rides/migrations/0007_alter_ridemodel_manufacturer.py
Normal 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",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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",
|
||||||
|
),
|
||||||
|
]
|
||||||
155
rides/models.py
155
rides/models.py
@@ -1,29 +1,68 @@
|
|||||||
from typing import Tuple, Optional, Any
|
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.contrib.contenttypes.fields import GenericRelation
|
|
||||||
from django.utils.text import slugify
|
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
|
from history_tracking.models import HistoricalModel
|
||||||
|
|
||||||
|
|
||||||
|
# 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):
|
class Ride(HistoricalModel):
|
||||||
CATEGORY_CHOICES = [
|
|
||||||
('RC', 'Roller Coaster'),
|
|
||||||
('DR', 'Dark Ride'),
|
|
||||||
('FR', 'Flat Ride'),
|
|
||||||
('WR', 'Water Ride'),
|
|
||||||
('TR', 'Transport'),
|
|
||||||
('OT', 'Other'),
|
|
||||||
]
|
|
||||||
|
|
||||||
STATUS_CHOICES = [
|
STATUS_CHOICES = [
|
||||||
('OPERATING', 'Operating'),
|
('OPERATING', 'Operating'),
|
||||||
('CLOSED_TEMP', 'Temporarily Closed'),
|
('SBNO', 'Standing But Not Operating'),
|
||||||
|
('CLOSING', 'Closing'),
|
||||||
('CLOSED_PERM', 'Permanently Closed'),
|
('CLOSED_PERM', 'Permanently Closed'),
|
||||||
('UNDER_CONSTRUCTION', 'Under Construction'),
|
('UNDER_CONSTRUCTION', 'Under Construction'),
|
||||||
('DEMOLISHED', 'Demolished'),
|
('DEMOLISHED', 'Demolished'),
|
||||||
('RELOCATED', 'Relocated'),
|
('RELOCATED', 'Relocated'),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
POST_CLOSING_STATUS_CHOICES = [
|
||||||
|
('SBNO', 'Standing But Not Operating'),
|
||||||
|
('CLOSED_PERM', 'Permanently Closed'),
|
||||||
|
]
|
||||||
|
|
||||||
name = models.CharField(max_length=255)
|
name = models.CharField(max_length=255)
|
||||||
slug = models.SlugField(max_length=255)
|
slug = models.SlugField(max_length=255)
|
||||||
description = models.TextField(blank=True)
|
description = models.TextField(blank=True)
|
||||||
@@ -42,34 +81,47 @@ class Ride(HistoricalModel):
|
|||||||
category = models.CharField(
|
category = models.CharField(
|
||||||
max_length=2,
|
max_length=2,
|
||||||
choices=CATEGORY_CHOICES,
|
choices=CATEGORY_CHOICES,
|
||||||
default='OT'
|
default='',
|
||||||
|
blank=True
|
||||||
)
|
)
|
||||||
manufacturer = models.ForeignKey(
|
manufacturer = models.ForeignKey(
|
||||||
'companies.manufacturer',
|
'companies.Manufacturer',
|
||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
null=False,
|
null=True,
|
||||||
blank=False
|
blank=True
|
||||||
)
|
)
|
||||||
designer = models.ForeignKey(
|
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,
|
on_delete=models.SET_NULL,
|
||||||
related_name='rides',
|
related_name='rides',
|
||||||
null=True,
|
null=True,
|
||||||
blank=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(
|
status = models.CharField(
|
||||||
max_length=20,
|
max_length=20,
|
||||||
choices=STATUS_CHOICES,
|
choices=STATUS_CHOICES,
|
||||||
default='OPERATING'
|
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)
|
opening_date = models.DateField(null=True, blank=True)
|
||||||
closing_date = models.DateField(null=True, blank=True)
|
closing_date = models.DateField(null=True, blank=True)
|
||||||
status_since = models.DateField(null=True, blank=True)
|
status_since = models.DateField(null=True, blank=True)
|
||||||
min_height_in = models.PositiveIntegerField(null=True, blank=True)
|
min_height_in = models.PositiveIntegerField(null=True, blank=True)
|
||||||
max_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)
|
capacity_per_hour = models.PositiveIntegerField(null=True, blank=True)
|
||||||
ride_duration_seconds = models.PositiveIntegerField(null=True, blank=True)
|
ride_duration_seconds = models.PositiveIntegerField(null=True, blank=True)
|
||||||
average_rating = models.DecimalField(
|
average_rating = models.DecimalField(
|
||||||
@@ -90,64 +142,25 @@ class Ride(HistoricalModel):
|
|||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
return f"{self.name} at {self.park.name}"
|
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:
|
if not self.slug:
|
||||||
self.slug = slugify(self.name)
|
self.slug = slugify(self.name)
|
||||||
super().save(*args, **kwargs)
|
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 = [
|
TRACK_MATERIAL_CHOICES = [
|
||||||
('STEEL', 'Steel'),
|
('STEEL', 'Steel'),
|
||||||
('WOOD', 'Wood'),
|
('WOOD', 'Wood'),
|
||||||
('HYBRID', 'Hybrid'),
|
('HYBRID', 'Hybrid'),
|
||||||
('OTHER', 'Other'),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
COASTER_TYPE_CHOICES = [
|
COASTER_TYPE_CHOICES = [
|
||||||
('SITDOWN', 'Sit-Down'),
|
('SITDOWN', 'Sit Down'),
|
||||||
('INVERTED', 'Inverted'),
|
('INVERTED', 'Inverted'),
|
||||||
('FLYING', 'Flying'),
|
('FLYING', 'Flying'),
|
||||||
('STANDUP', 'Stand-Up'),
|
('STANDUP', 'Stand Up'),
|
||||||
('WING', 'Wing'),
|
('WING', 'Wing'),
|
||||||
('SUSPENDED', 'Suspended'),
|
|
||||||
('BOBSLED', 'Bobsled'),
|
|
||||||
('PIPELINE', 'Pipeline'),
|
|
||||||
('MOTORBIKE', 'Motorbike'),
|
|
||||||
('FLOORLESS', 'Floorless'),
|
|
||||||
('DIVE', 'Dive'),
|
('DIVE', 'Dive'),
|
||||||
('FAMILY', 'Family'),
|
('FAMILY', 'Family'),
|
||||||
('WILD_MOUSE', 'Wild Mouse'),
|
('WILD_MOUSE', 'Wild Mouse'),
|
||||||
@@ -156,6 +169,14 @@ class RollerCoasterStats(HistoricalModel):
|
|||||||
('OTHER', 'Other'),
|
('OTHER', 'Other'),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
LAUNCH_CHOICES = [
|
||||||
|
('CHAIN', 'Chain Lift'),
|
||||||
|
('LSM', 'LSM Launch'),
|
||||||
|
('HYDRAULIC', 'Hydraulic Launch'),
|
||||||
|
('GRAVITY', 'Gravity'),
|
||||||
|
('OTHER', 'Other'),
|
||||||
|
]
|
||||||
|
|
||||||
ride = models.OneToOneField(
|
ride = models.OneToOneField(
|
||||||
Ride,
|
Ride,
|
||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
@@ -192,15 +213,13 @@ class RollerCoasterStats(HistoricalModel):
|
|||||||
max_length=20,
|
max_length=20,
|
||||||
choices=COASTER_TYPE_CHOICES,
|
choices=COASTER_TYPE_CHOICES,
|
||||||
default='SITDOWN',
|
default='SITDOWN',
|
||||||
blank=True,
|
blank=True
|
||||||
help_text='The type/style of roller coaster (e.g. Sit-Down, Inverted, Flying)'
|
|
||||||
)
|
)
|
||||||
max_drop_height_ft = models.DecimalField(
|
max_drop_height_ft = models.DecimalField(
|
||||||
max_digits=6,
|
max_digits=6,
|
||||||
decimal_places=2,
|
decimal_places=2,
|
||||||
null=True,
|
null=True,
|
||||||
blank=True,
|
blank=True
|
||||||
help_text='Maximum vertical drop height in feet'
|
|
||||||
)
|
)
|
||||||
launch_type = models.CharField(
|
launch_type = models.CharField(
|
||||||
max_length=20,
|
max_length=20,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,21 +1,62 @@
|
|||||||
from django.urls import path
|
from django.urls import path
|
||||||
from . import views
|
from . import views
|
||||||
|
|
||||||
app_name = 'rides' # Add namespace
|
app_name = "rides"
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
# Global category URLs
|
# List views
|
||||||
path('', views.RideListView.as_view(), name='ride_list'),
|
path("", views.RideListView.as_view(), name="ride_list"),
|
||||||
path('all/', views.RideListView.as_view(), name='all_rides'),
|
path("create/", views.RideCreateView.as_view(), name="ride_create"),
|
||||||
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'),
|
|
||||||
|
|
||||||
# Basic ride URLs
|
# Search endpoints
|
||||||
path('create/', views.RideCreateView.as_view(), name='ride_create'),
|
path(
|
||||||
path('<slug:ride_slug>/edit/', views.RideUpdateView.as_view(), name='ride_edit'),
|
"search/manufacturers/", views.search_manufacturers, name="search_manufacturers"
|
||||||
path('<slug:ride_slug>/', views.RideDetailView.as_view(), name='ride_detail'),
|
),
|
||||||
|
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"),
|
||||||
]
|
]
|
||||||
|
|||||||
736
rides/views.py
736
rides/views.py
@@ -1,10 +1,11 @@
|
|||||||
from typing import Any, Dict, Optional, Tuple, Union, cast
|
from typing import Any, Dict, Optional, Tuple, Union, cast, Type
|
||||||
from django.views.generic import DetailView, ListView, CreateView, UpdateView
|
from django.views.generic import DetailView, ListView, CreateView, UpdateView, RedirectView
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404, render
|
||||||
from django.core.serializers.json import DjangoJSONEncoder
|
from django.core.serializers.json import DjangoJSONEncoder
|
||||||
from django.urls import reverse
|
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.mixins import LoginRequiredMixin
|
||||||
|
from django.contrib.auth.decorators import login_required
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.http import (
|
from django.http import (
|
||||||
@@ -17,7 +18,11 @@ from django.http import (
|
|||||||
from django.db.models import Count
|
from django.db.models import Count
|
||||||
from django.core.files.uploadedfile import UploadedFile
|
from django.core.files.uploadedfile import UploadedFile
|
||||||
from django.forms import ModelForm
|
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 .forms import RideForm
|
||||||
from parks.models import Park
|
from parks.models import Park
|
||||||
from core.views import SlugRedirectMixin
|
from core.views import SlugRedirectMixin
|
||||||
@@ -25,481 +30,328 @@ from moderation.mixins import EditSubmissionMixin, PhotoSubmissionMixin, History
|
|||||||
from moderation.models import EditSubmission
|
from moderation.models import EditSubmission
|
||||||
from media.models import Photo
|
from media.models import Photo
|
||||||
from accounts.models import User
|
from accounts.models import User
|
||||||
|
from companies.models import Manufacturer, Designer
|
||||||
|
|
||||||
|
|
||||||
def is_privileged_user(user: Any) -> bool:
|
def show_coaster_fields(request: HttpRequest) -> HttpResponse:
|
||||||
"""Check if the user has privileged access.
|
"""Show roller coaster specific fields based on category selection"""
|
||||||
|
category = request.GET.get('category')
|
||||||
Args:
|
if category != 'RC': # Only show for roller coasters
|
||||||
user: The user to check
|
return HttpResponse('')
|
||||||
|
return render(request, "rides/partials/coaster_fields.html")
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
class RideCreateView(LoginRequiredMixin, CreateView):
|
class RideCreateView(LoginRequiredMixin, CreateView):
|
||||||
|
"""View for creating a new ride"""
|
||||||
model = Ride
|
model = Ride
|
||||||
form_class = RideForm
|
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:
|
def get_success_url(self):
|
||||||
super().setup(request, *args, **kwargs)
|
"""Get URL to redirect to after successful creation"""
|
||||||
self.park = get_object_or_404(Park, slug=self.kwargs["park_slug"])
|
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 = 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
|
return kwargs
|
||||||
|
|
||||||
def handle_submission(
|
def get_context_data(self, **kwargs):
|
||||||
self, form: RideForm, cleaned_data: Dict[str, Any]
|
"""Add park and park_slug to context"""
|
||||||
) -> 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]:
|
|
||||||
context = super().get_context_data(**kwargs)
|
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
|
return context
|
||||||
|
|
||||||
|
def form_valid(self, form):
|
||||||
class RideUpdateView(LoginRequiredMixin, UpdateView):
|
"""Handle form submission including new items"""
|
||||||
model = Ride
|
# Check for new manufacturer
|
||||||
form_class = RideForm
|
manufacturer_name = form.cleaned_data.get('manufacturer_search')
|
||||||
template_name = "rides/ride_form.html"
|
if manufacturer_name and not form.cleaned_data.get('manufacturer'):
|
||||||
slug_url_kwarg = "ride_slug"
|
# Create submission for new manufacturer
|
||||||
|
EditSubmission.objects.create(
|
||||||
def setup(self, request: HttpRequest, *args: Any, **kwargs: Any) -> None:
|
user=self.request.user,
|
||||||
super().setup(request, *args, **kwargs)
|
content_type=ContentType.objects.get_for_model(Manufacturer),
|
||||||
self.park = get_object_or_404(Park, slug=self.kwargs["park_slug"])
|
submission_type="CREATE",
|
||||||
|
changes={"name": manufacturer_name},
|
||||||
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:
|
# Check for new designer
|
||||||
cleaned_data = prepare_form_data(form.cleaned_data, self.park)
|
designer_name = form.cleaned_data.get('designer_search')
|
||||||
return self.handle_submission(form, cleaned_data)
|
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},
|
||||||
|
)
|
||||||
|
|
||||||
def form_invalid(self, form: RideForm) -> Union[HttpResponse, HttpResponseRedirect]:
|
# Check for new ride model
|
||||||
"""Handle invalid form submission.
|
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
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
Args:
|
return super().form_valid(form)
|
||||||
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(
|
class RideDetailView(DetailView):
|
||||||
SlugRedirectMixin,
|
"""View for displaying ride details"""
|
||||||
EditSubmissionMixin,
|
|
||||||
PhotoSubmissionMixin,
|
|
||||||
HistoryMixin,
|
|
||||||
DetailView,
|
|
||||||
):
|
|
||||||
model = Ride
|
model = Ride
|
||||||
template_name = "rides/ride_detail.html"
|
template_name = 'rides/ride_detail.html'
|
||||||
context_object_name = "ride"
|
slug_url_kwarg = 'ride_slug'
|
||||||
slug_url_kwarg = "ride_slug"
|
|
||||||
|
|
||||||
def get_object(self, queryset=None):
|
def get_queryset(self):
|
||||||
if queryset is None:
|
"""Get ride for the specific park if park_slug is provided"""
|
||||||
queryset = self.get_queryset()
|
queryset = Ride.objects.all().select_related(
|
||||||
park_slug = self.kwargs.get("park_slug")
|
'park',
|
||||||
ride_slug = self.kwargs.get("ride_slug")
|
'ride_model',
|
||||||
obj, is_old_slug = self.model.get_by_slug(ride_slug) # type: ignore[attr-defined]
|
'ride_model__manufacturer'
|
||||||
if obj.park.slug != park_slug:
|
).prefetch_related('photos')
|
||||||
raise self.model.DoesNotExist("Park slug doesn't match")
|
|
||||||
return obj
|
|
||||||
|
|
||||||
def get_context_data(self, **kwargs) -> Dict[str, Any]:
|
if 'park_slug' in self.kwargs:
|
||||||
|
queryset = queryset.filter(park__slug=self.kwargs['park_slug'])
|
||||||
|
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
"""Add park_slug to context if it exists"""
|
||||||
context = super().get_context_data(**kwargs)
|
context = super().get_context_data(**kwargs)
|
||||||
if self.object.category == "RC":
|
if 'park_slug' in self.kwargs:
|
||||||
context["coaster_stats"] = RollerCoasterStats.objects.filter(
|
context['park_slug'] = self.kwargs['park_slug']
|
||||||
ride=self.object
|
|
||||||
).first()
|
|
||||||
return context
|
return context
|
||||||
|
|
||||||
def get_redirect_url_pattern(self) -> str:
|
|
||||||
return "parks:rides:ride_detail"
|
|
||||||
|
|
||||||
def get_redirect_url_kwargs(self) -> Dict[str, Any]:
|
class RideUpdateView(LoginRequiredMixin, EditSubmissionMixin, UpdateView):
|
||||||
return {"park_slug": self.object.park.slug, "ride_slug": self.object.slug}
|
"""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):
|
class RideListView(ListView):
|
||||||
|
"""View for displaying a list of rides"""
|
||||||
model = Ride
|
model = Ride
|
||||||
template_name = "rides/ride_list.html"
|
template_name = 'rides/ride_list.html'
|
||||||
context_object_name = "rides"
|
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"])
|
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
queryset = Ride.objects.select_related(
|
"""Get all rides or filter by park if park_slug is provided"""
|
||||||
"park", "coaster_stats", "manufacturer"
|
queryset = Ride.objects.all().select_related(
|
||||||
).prefetch_related("photos")
|
'park',
|
||||||
|
'ride_model',
|
||||||
|
'ride_model__manufacturer'
|
||||||
|
).prefetch_related('photos')
|
||||||
|
|
||||||
if self.park:
|
if 'park_slug' in self.kwargs:
|
||||||
|
self.park = get_object_or_404(Park, slug=self.kwargs['park_slug'])
|
||||||
queryset = queryset.filter(park=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)
|
|
||||||
|
|
||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
def get_context_data(self, **kwargs) -> Dict[str, Any]:
|
def get_context_data(self, **kwargs):
|
||||||
|
"""Add park to context if park_slug is provided"""
|
||||||
context = super().get_context_data(**kwargs)
|
context = super().get_context_data(**kwargs)
|
||||||
context["park"] = self.park
|
if hasattr(self, 'park'):
|
||||||
|
context['park'] = self.park
|
||||||
manufacturer_query = Ride.objects
|
context['park_slug'] = self.kwargs['park_slug']
|
||||||
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", ""),
|
|
||||||
}
|
|
||||||
|
|
||||||
return context
|
return context
|
||||||
|
|
||||||
def get(self, request: HttpRequest, *args: Any, **kwargs: Any):
|
|
||||||
if getattr(request, "htmx", False): # type: ignore[attr-defined]
|
class SingleCategoryListView(ListView):
|
||||||
self.template_name = "rides/partials/ride_list.html"
|
"""View for displaying rides of a specific category"""
|
||||||
return super().get(request, *args, **kwargs)
|
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},
|
||||||
|
)
|
||||||
|
|||||||
@@ -749,35 +749,59 @@ select {
|
|||||||
--tw-contain-style: ;
|
--tw-contain-style: ;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.\!container {
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 640px) {
|
@media (min-width: 640px) {
|
||||||
|
.\!container {
|
||||||
|
max-width: 640px !important;
|
||||||
|
}
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
max-width: 640px;
|
max-width: 640px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
|
.\!container {
|
||||||
|
max-width: 768px !important;
|
||||||
|
}
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
max-width: 768px;
|
max-width: 768px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
@media (min-width: 1024px) {
|
||||||
|
.\!container {
|
||||||
|
max-width: 1024px !important;
|
||||||
|
}
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
max-width: 1024px;
|
max-width: 1024px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 1280px) {
|
@media (min-width: 1280px) {
|
||||||
|
.\!container {
|
||||||
|
max-width: 1280px !important;
|
||||||
|
}
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
max-width: 1280px;
|
max-width: 1280px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 1536px) {
|
@media (min-width: 1536px) {
|
||||||
|
.\!container {
|
||||||
|
max-width: 1536px !important;
|
||||||
|
}
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
max-width: 1536px;
|
max-width: 1536px;
|
||||||
}
|
}
|
||||||
@@ -2157,6 +2181,10 @@ select {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.visible {
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
|
||||||
.static {
|
.static {
|
||||||
position: static;
|
position: static;
|
||||||
}
|
}
|
||||||
@@ -2245,6 +2273,16 @@ select {
|
|||||||
grid-column: 1 / -1;
|
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 {
|
.mx-1 {
|
||||||
margin-left: 0.25rem;
|
margin-left: 0.25rem;
|
||||||
margin-right: 0.25rem;
|
margin-right: 0.25rem;
|
||||||
@@ -2270,6 +2308,11 @@ select {
|
|||||||
margin-right: auto;
|
margin-right: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.my-auto {
|
||||||
|
margin-top: auto;
|
||||||
|
margin-bottom: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.-mb-px {
|
.-mb-px {
|
||||||
margin-bottom: -1px;
|
margin-bottom: -1px;
|
||||||
}
|
}
|
||||||
@@ -2314,22 +2357,42 @@ select {
|
|||||||
margin-left: 0.25rem;
|
margin-left: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ml-1\.5 {
|
||||||
|
margin-left: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
.ml-2 {
|
.ml-2 {
|
||||||
margin-left: 0.5rem;
|
margin-left: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ml-3 {
|
||||||
|
margin-left: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
.ml-6 {
|
.ml-6 {
|
||||||
margin-left: 1.5rem;
|
margin-left: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ml-auto {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.mr-1 {
|
.mr-1 {
|
||||||
margin-right: 0.25rem;
|
margin-right: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mr-1\.5 {
|
||||||
|
margin-right: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
.mr-2 {
|
.mr-2 {
|
||||||
margin-right: 0.5rem;
|
margin-right: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mr-2\.5 {
|
||||||
|
margin-right: 0.625rem;
|
||||||
|
}
|
||||||
|
|
||||||
.mr-3 {
|
.mr-3 {
|
||||||
margin-right: 0.75rem;
|
margin-right: 0.75rem;
|
||||||
}
|
}
|
||||||
@@ -2342,6 +2405,10 @@ select {
|
|||||||
margin-top: 0.25rem;
|
margin-top: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mt-1\.5 {
|
||||||
|
margin-top: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
.mt-2 {
|
.mt-2 {
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
}
|
}
|
||||||
@@ -2442,10 +2509,6 @@ select {
|
|||||||
max-height: 90vh;
|
max-height: 90vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
.min-h-\[calc\(100vh-16rem\)\] {
|
|
||||||
min-height: calc(100vh - 16rem);
|
|
||||||
}
|
|
||||||
|
|
||||||
.min-h-screen {
|
.min-h-screen {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
@@ -2491,6 +2554,10 @@ select {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.min-w-\[200px\] {
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
.max-w-2xl {
|
.max-w-2xl {
|
||||||
max-width: 42rem;
|
max-width: 42rem;
|
||||||
}
|
}
|
||||||
@@ -2503,10 +2570,18 @@ select {
|
|||||||
max-width: 56rem;
|
max-width: 56rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.max-w-6xl {
|
||||||
|
max-width: 72rem;
|
||||||
|
}
|
||||||
|
|
||||||
.max-w-7xl {
|
.max-w-7xl {
|
||||||
max-width: 80rem;
|
max-width: 80rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.max-w-\[800px\] {
|
||||||
|
max-width: 800px;
|
||||||
|
}
|
||||||
|
|
||||||
.max-w-lg {
|
.max-w-lg {
|
||||||
max-width: 32rem;
|
max-width: 32rem;
|
||||||
}
|
}
|
||||||
@@ -2519,10 +2594,18 @@ select {
|
|||||||
max-width: none;
|
max-width: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.max-w-xs {
|
||||||
|
max-width: 20rem;
|
||||||
|
}
|
||||||
|
|
||||||
.flex-1 {
|
.flex-1 {
|
||||||
flex: 1 1 0%;
|
flex: 1 1 0%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.flex-shrink-0 {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.flex-grow {
|
.flex-grow {
|
||||||
flex-grow: 1;
|
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));
|
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 {
|
.scale-100 {
|
||||||
--tw-scale-x: 1;
|
--tw-scale-x: 1;
|
||||||
--tw-scale-y: 1;
|
--tw-scale-y: 1;
|
||||||
@@ -2572,6 +2660,10 @@ select {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.resize-none {
|
||||||
|
resize: none;
|
||||||
|
}
|
||||||
|
|
||||||
.grid-cols-1 {
|
.grid-cols-1 {
|
||||||
grid-template-columns: repeat(1, minmax(0, 1fr));
|
grid-template-columns: repeat(1, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
@@ -2632,6 +2724,10 @@ select {
|
|||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.gap-3 {
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
.gap-4 {
|
.gap-4 {
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
@@ -2652,6 +2748,12 @@ select {
|
|||||||
margin-left: calc(0.5rem * calc(1 - var(--tw-space-x-reverse)));
|
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]) {
|
.space-x-4 > :not([hidden]) ~ :not([hidden]) {
|
||||||
--tw-space-x-reverse: 0;
|
--tw-space-x-reverse: 0;
|
||||||
margin-right: calc(1rem * var(--tw-space-x-reverse));
|
margin-right: calc(1rem * var(--tw-space-x-reverse));
|
||||||
@@ -2696,6 +2798,10 @@ select {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.overflow-y-auto {
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.rounded {
|
.rounded {
|
||||||
border-radius: 0.25rem;
|
border-radius: 0.25rem;
|
||||||
}
|
}
|
||||||
@@ -2770,6 +2876,10 @@ select {
|
|||||||
border-style: dashed;
|
border-style: dashed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.border-blue-200\/50 {
|
||||||
|
border-color: rgb(191 219 254 / 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
.border-blue-500 {
|
.border-blue-500 {
|
||||||
--tw-border-opacity: 1;
|
--tw-border-opacity: 1;
|
||||||
border-color: rgb(59 130 246 / var(--tw-border-opacity));
|
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-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 {
|
.border-green-500 {
|
||||||
--tw-border-opacity: 1;
|
--tw-border-opacity: 1;
|
||||||
border-color: rgb(34 197 94 / var(--tw-border-opacity));
|
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 {
|
.border-red-400 {
|
||||||
--tw-border-opacity: 1;
|
--tw-border-opacity: 1;
|
||||||
border-color: rgb(248 113 113 / var(--tw-border-opacity));
|
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));
|
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 {
|
.bg-gray-100 {
|
||||||
--tw-bg-opacity: 1;
|
--tw-bg-opacity: 1;
|
||||||
background-color: rgb(243 244 246 / var(--tw-bg-opacity));
|
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));
|
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 {
|
.bg-green-100 {
|
||||||
--tw-bg-opacity: 1;
|
--tw-bg-opacity: 1;
|
||||||
background-color: rgb(220 252 231 / var(--tw-bg-opacity));
|
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));
|
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 {
|
.bg-red-100 {
|
||||||
--tw-bg-opacity: 1;
|
--tw-bg-opacity: 1;
|
||||||
background-color: rgb(254 226 226 / var(--tw-bg-opacity));
|
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));
|
background-color: rgb(220 38 38 / var(--tw-bg-opacity));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bg-red-900\/40 {
|
||||||
|
background-color: rgb(127 29 29 / 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
.bg-white {
|
.bg-white {
|
||||||
--tw-bg-opacity: 1;
|
--tw-bg-opacity: 1;
|
||||||
background-color: rgb(255 255 255 / var(--tw-bg-opacity));
|
background-color: rgb(255 255 255 / var(--tw-bg-opacity));
|
||||||
@@ -2900,6 +3051,10 @@ select {
|
|||||||
background-color: rgb(255 255 255 / 0.1);
|
background-color: rgb(255 255 255 / 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bg-white\/80 {
|
||||||
|
background-color: rgb(255 255 255 / 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
.bg-white\/90 {
|
.bg-white\/90 {
|
||||||
background-color: rgb(255 255 255 / 0.9);
|
background-color: rgb(255 255 255 / 0.9);
|
||||||
}
|
}
|
||||||
@@ -2919,10 +3074,18 @@ select {
|
|||||||
background-color: rgb(202 138 4 / var(--tw-bg-opacity));
|
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 {
|
.bg-opacity-50 {
|
||||||
--tw-bg-opacity: 0.5;
|
--tw-bg-opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bg-opacity-75 {
|
||||||
|
--tw-bg-opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
.bg-opacity-90 {
|
.bg-opacity-90 {
|
||||||
--tw-bg-opacity: 0.9;
|
--tw-bg-opacity: 0.9;
|
||||||
}
|
}
|
||||||
@@ -2965,6 +3128,11 @@ select {
|
|||||||
background-clip: text;
|
background-clip: text;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.object-contain {
|
||||||
|
-o-object-fit: contain;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
.object-cover {
|
.object-cover {
|
||||||
-o-object-fit: cover;
|
-o-object-fit: cover;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
@@ -3047,6 +3215,11 @@ select {
|
|||||||
padding-bottom: 0.5rem;
|
padding-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.py-2\.5 {
|
||||||
|
padding-top: 0.625rem;
|
||||||
|
padding-bottom: 0.625rem;
|
||||||
|
}
|
||||||
|
|
||||||
.py-3 {
|
.py-3 {
|
||||||
padding-top: 0.75rem;
|
padding-top: 0.75rem;
|
||||||
padding-bottom: 0.75rem;
|
padding-bottom: 0.75rem;
|
||||||
@@ -3071,6 +3244,10 @@ select {
|
|||||||
padding-bottom: 1rem;
|
padding-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.text-left {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
.text-center {
|
.text-center {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
@@ -3090,6 +3267,11 @@ select {
|
|||||||
line-height: 2.5rem;
|
line-height: 2.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.text-5xl {
|
||||||
|
font-size: 3rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.text-lg {
|
.text-lg {
|
||||||
font-size: 1.125rem;
|
font-size: 1.125rem;
|
||||||
line-height: 1.75rem;
|
line-height: 1.75rem;
|
||||||
@@ -3118,14 +3300,31 @@ select {
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.font-normal {
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
.font-semibold {
|
.font-semibold {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.uppercase {
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lowercase {
|
||||||
|
text-transform: lowercase;
|
||||||
|
}
|
||||||
|
|
||||||
.leading-tight {
|
.leading-tight {
|
||||||
line-height: 1.25;
|
line-height: 1.25;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.text-blue-400 {
|
||||||
|
--tw-text-opacity: 1;
|
||||||
|
color: rgb(96 165 250 / var(--tw-text-opacity));
|
||||||
|
}
|
||||||
|
|
||||||
.text-blue-500 {
|
.text-blue-500 {
|
||||||
--tw-text-opacity: 1;
|
--tw-text-opacity: 1;
|
||||||
color: rgb(59 130 246 / var(--tw-text-opacity));
|
color: rgb(59 130 246 / var(--tw-text-opacity));
|
||||||
@@ -3136,16 +3335,16 @@ select {
|
|||||||
color: rgb(37 99 235 / var(--tw-text-opacity));
|
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 {
|
.text-blue-800 {
|
||||||
--tw-text-opacity: 1;
|
--tw-text-opacity: 1;
|
||||||
color: rgb(30 64 175 / var(--tw-text-opacity));
|
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 {
|
.text-gray-200 {
|
||||||
--tw-text-opacity: 1;
|
--tw-text-opacity: 1;
|
||||||
color: rgb(229 231 235 / var(--tw-text-opacity));
|
color: rgb(229 231 235 / var(--tw-text-opacity));
|
||||||
@@ -3181,11 +3380,21 @@ select {
|
|||||||
color: rgb(17 24 39 / var(--tw-text-opacity));
|
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 {
|
.text-green-600 {
|
||||||
--tw-text-opacity: 1;
|
--tw-text-opacity: 1;
|
||||||
color: rgb(22 163 74 / var(--tw-text-opacity));
|
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 {
|
.text-green-800 {
|
||||||
--tw-text-opacity: 1;
|
--tw-text-opacity: 1;
|
||||||
color: rgb(22 101 52 / var(--tw-text-opacity));
|
color: rgb(22 101 52 / var(--tw-text-opacity));
|
||||||
@@ -3196,6 +3405,11 @@ select {
|
|||||||
color: rgb(79 70 229 / var(--tw-text-opacity));
|
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 {
|
.text-red-500 {
|
||||||
--tw-text-opacity: 1;
|
--tw-text-opacity: 1;
|
||||||
color: rgb(239 68 68 / var(--tw-text-opacity));
|
color: rgb(239 68 68 / var(--tw-text-opacity));
|
||||||
@@ -3245,6 +3459,11 @@ select {
|
|||||||
color: rgb(202 138 4 / var(--tw-text-opacity));
|
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 {
|
.text-yellow-800 {
|
||||||
--tw-text-opacity: 1;
|
--tw-text-opacity: 1;
|
||||||
color: rgb(133 77 14 / var(--tw-text-opacity));
|
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-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 {
|
||||||
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, -webkit-backdrop-filter;
|
||||||
transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, 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));
|
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 {
|
.hover\:bg-blue-700:hover {
|
||||||
--tw-bg-opacity: 1;
|
--tw-bg-opacity: 1;
|
||||||
background-color: rgb(29 78 216 / var(--tw-bg-opacity));
|
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));
|
background-color: rgb(249 250 251 / var(--tw-bg-opacity));
|
||||||
}
|
}
|
||||||
|
|
||||||
.hover\:bg-green-700:hover {
|
.hover\:bg-green-500:hover {
|
||||||
--tw-bg-opacity: 1;
|
--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 {
|
.hover\:bg-red-50:hover {
|
||||||
@@ -3457,6 +3692,11 @@ select {
|
|||||||
background-color: rgb(254 242 242 / var(--tw-bg-opacity));
|
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 {
|
.hover\:bg-red-700:hover {
|
||||||
--tw-bg-opacity: 1;
|
--tw-bg-opacity: 1;
|
||||||
background-color: rgb(185 28 28 / var(--tw-bg-opacity));
|
background-color: rgb(185 28 28 / var(--tw-bg-opacity));
|
||||||
@@ -3466,6 +3706,11 @@ select {
|
|||||||
background-color: rgb(255 255 255 / 0.2);
|
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 {
|
.hover\:bg-yellow-600:hover {
|
||||||
--tw-bg-opacity: 1;
|
--tw-bg-opacity: 1;
|
||||||
background-color: rgb(202 138 4 / var(--tw-bg-opacity));
|
background-color: rgb(202 138 4 / var(--tw-bg-opacity));
|
||||||
@@ -3486,6 +3731,16 @@ select {
|
|||||||
color: rgb(29 78 216 / var(--tw-text-opacity));
|
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 {
|
.hover\:text-gray-300:hover {
|
||||||
--tw-text-opacity: 1;
|
--tw-text-opacity: 1;
|
||||||
color: rgb(209 213 219 / var(--tw-text-opacity));
|
color: rgb(209 213 219 / var(--tw-text-opacity));
|
||||||
@@ -3501,6 +3756,11 @@ select {
|
|||||||
color: rgb(55 65 81 / var(--tw-text-opacity));
|
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 {
|
.hover\:text-primary:hover {
|
||||||
--tw-text-opacity: 1;
|
--tw-text-opacity: 1;
|
||||||
color: rgb(79 70 229 / var(--tw-text-opacity));
|
color: rgb(79 70 229 / var(--tw-text-opacity));
|
||||||
@@ -3564,6 +3824,10 @@ select {
|
|||||||
--tw-ring-offset-width: 2px;
|
--tw-ring-offset-width: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.disabled\:opacity-50:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
.group:hover .group-hover\:scale-105 {
|
.group:hover .group-hover\:scale-105 {
|
||||||
--tw-scale-x: 1.05;
|
--tw-scale-x: 1.05;
|
||||||
--tw-scale-y: 1.05;
|
--tw-scale-y: 1.05;
|
||||||
@@ -3574,6 +3838,10 @@ select {
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dark\:border-blue-700\/50:is(.dark *) {
|
||||||
|
border-color: rgb(29 78 216 / 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
.dark\:border-gray-600:is(.dark *) {
|
.dark\:border-gray-600:is(.dark *) {
|
||||||
--tw-border-opacity: 1;
|
--tw-border-opacity: 1;
|
||||||
border-color: rgb(75 85 99 / var(--tw-border-opacity));
|
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));
|
background-color: rgb(29 78 216 / var(--tw-bg-opacity));
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark\:bg-blue-900:is(.dark *) {
|
.dark\:bg-blue-900\/30:is(.dark *) {
|
||||||
--tw-bg-opacity: 1;
|
background-color: rgb(30 58 138 / 0.3);
|
||||||
background-color: rgb(30 58 138 / var(--tw-bg-opacity));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark\:bg-blue-900\/50:is(.dark *) {
|
.dark\:bg-blue-900\/40:is(.dark *) {
|
||||||
background-color: rgb(30 58 138 / 0.5);
|
background-color: rgb(30 58 138 / 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark\:bg-gray-600:is(.dark *) {
|
.dark\:bg-gray-600:is(.dark *) {
|
||||||
@@ -3634,39 +3901,38 @@ select {
|
|||||||
background-color: rgb(31 41 55 / 0.9);
|
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 *) {
|
.dark\:bg-green-200:is(.dark *) {
|
||||||
--tw-bg-opacity: 1;
|
--tw-bg-opacity: 1;
|
||||||
background-color: rgb(187 247 208 / var(--tw-bg-opacity));
|
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 *) {
|
.dark\:bg-green-700:is(.dark *) {
|
||||||
--tw-bg-opacity: 1;
|
--tw-bg-opacity: 1;
|
||||||
background-color: rgb(21 128 61 / var(--tw-bg-opacity));
|
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 *) {
|
.dark\:bg-red-200:is(.dark *) {
|
||||||
--tw-bg-opacity: 1;
|
--tw-bg-opacity: 1;
|
||||||
background-color: rgb(254 202 202 / var(--tw-bg-opacity));
|
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;
|
--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;
|
--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 *) {
|
.dark\:bg-yellow-400\/30:is(.dark *) {
|
||||||
@@ -3678,9 +3944,13 @@ select {
|
|||||||
background-color: rgb(202 138 4 / var(--tw-bg-opacity));
|
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;
|
--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 *) {
|
.dark\:from-gray-950:is(.dark *) {
|
||||||
@@ -3703,6 +3973,11 @@ select {
|
|||||||
color: rgb(191 219 254 / var(--tw-text-opacity));
|
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 *) {
|
.dark\:text-blue-400:is(.dark *) {
|
||||||
--tw-text-opacity: 1;
|
--tw-text-opacity: 1;
|
||||||
color: rgb(96 165 250 / var(--tw-text-opacity));
|
color: rgb(96 165 250 / var(--tw-text-opacity));
|
||||||
@@ -3743,11 +4018,6 @@ select {
|
|||||||
color: rgb(75 85 99 / var(--tw-text-opacity));
|
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 *) {
|
.dark\:text-green-400:is(.dark *) {
|
||||||
--tw-text-opacity: 1;
|
--tw-text-opacity: 1;
|
||||||
color: rgb(74 222 128 / var(--tw-text-opacity));
|
color: rgb(74 222 128 / var(--tw-text-opacity));
|
||||||
@@ -3758,16 +4028,16 @@ select {
|
|||||||
color: rgb(240 253 244 / var(--tw-text-opacity));
|
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 *) {
|
.dark\:text-green-900:is(.dark *) {
|
||||||
--tw-text-opacity: 1;
|
--tw-text-opacity: 1;
|
||||||
color: rgb(20 83 45 / var(--tw-text-opacity));
|
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 *) {
|
.dark\:text-red-400:is(.dark *) {
|
||||||
--tw-text-opacity: 1;
|
--tw-text-opacity: 1;
|
||||||
color: rgb(248 113 113 / var(--tw-text-opacity));
|
color: rgb(248 113 113 / var(--tw-text-opacity));
|
||||||
@@ -3813,6 +4083,11 @@ select {
|
|||||||
color: rgb(254 252 232 / var(--tw-text-opacity));
|
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 *) {
|
.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-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);
|
--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);
|
--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 *) {
|
.dark\:hover\:bg-blue-600:hover:is(.dark *) {
|
||||||
--tw-bg-opacity: 1;
|
--tw-bg-opacity: 1;
|
||||||
background-color: rgb(37 99 235 / var(--tw-bg-opacity));
|
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));
|
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 *) {
|
.dark\:hover\:bg-gray-500:hover:is(.dark *) {
|
||||||
--tw-bg-opacity: 1;
|
--tw-bg-opacity: 1;
|
||||||
background-color: rgb(107 114 128 / var(--tw-bg-opacity));
|
background-color: rgb(107 114 128 / var(--tw-bg-opacity));
|
||||||
@@ -3866,6 +4150,11 @@ select {
|
|||||||
background-color: rgb(127 29 29 / 0.2);
|
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 *) {
|
.dark\:hover\:text-blue-300:hover:is(.dark *) {
|
||||||
--tw-text-opacity: 1;
|
--tw-text-opacity: 1;
|
||||||
color: rgb(147 197 253 / var(--tw-text-opacity));
|
color: rgb(147 197 253 / var(--tw-text-opacity));
|
||||||
@@ -3876,6 +4165,11 @@ select {
|
|||||||
color: rgb(96 165 250 / var(--tw-text-opacity));
|
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 *) {
|
.dark\:hover\:text-gray-300:hover:is(.dark *) {
|
||||||
--tw-text-opacity: 1;
|
--tw-text-opacity: 1;
|
||||||
color: rgb(209 213 219 / var(--tw-text-opacity));
|
color: rgb(209 213 219 / var(--tw-text-opacity));
|
||||||
@@ -3891,6 +4185,11 @@ select {
|
|||||||
color: rgb(125 211 252 / var(--tw-text-opacity));
|
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) {
|
@media (min-width: 640px) {
|
||||||
.sm\:col-span-3 {
|
.sm\:col-span-3 {
|
||||||
grid-column: span 3 / span 3;
|
grid-column: span 3 / span 3;
|
||||||
@@ -3990,6 +4289,14 @@ select {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
@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 {
|
.md\:mb-8 {
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
}
|
}
|
||||||
@@ -4024,6 +4331,11 @@ select {
|
|||||||
margin-left: calc(0.75rem * calc(1 - var(--tw-space-x-reverse)));
|
margin-left: calc(0.75rem * calc(1 - var(--tw-space-x-reverse)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.md\:py-12 {
|
||||||
|
padding-top: 3rem;
|
||||||
|
padding-bottom: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
.md\:text-2xl {
|
.md\:text-2xl {
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
line-height: 2rem;
|
line-height: 2rem;
|
||||||
|
|||||||
@@ -6,8 +6,8 @@
|
|||||||
{% block title %}Login - ThrillWiki{% endblock %}
|
{% block title %}Login - ThrillWiki{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="flex items-center justify-center min-h-[calc(100vh-16rem)]">
|
<div class="flex items-center justify-center py-8 md:py-12">
|
||||||
<div class="w-full max-w-lg">
|
<div class="w-full max-w-lg px-4">
|
||||||
<div class="auth-card">
|
<div class="auth-card">
|
||||||
<h1 class="auth-title">{% trans "Welcome Back" %}</h1>
|
<h1 class="auth-title">{% trans "Welcome Back" %}</h1>
|
||||||
|
|
||||||
|
|||||||
71
templates/account/partials/login_modal.html
Normal file
71
templates/account/partials/login_modal.html
Normal 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
Reference in New Issue
Block a user