From eb5d2acab552440e8b58e864efd29920d8e3036e Mon Sep 17 00:00:00 2001 From: pacnpal <183241239+pacnpal@users.noreply.github.com> Date: Tue, 5 Nov 2024 21:51:02 +0000 Subject: [PATCH] fixed a bunch of things, hopefully didn't break things --- companies/__pycache__/models.cpython-312.pyc | Bin 5415 -> 5616 bytes companies/__pycache__/views.cpython-312.pyc | Bin 18654 -> 20800 bytes companies/models.py | 77 +-- companies/tests.py | 82 ++-- companies/views.py | 465 ++++++++++--------- core/__pycache__/views.cpython-312.pyc | Bin 2552 -> 3474 bytes core/views.py | 29 +- history_tracking/migrations/0003_initial.py | 54 +++ history_tracking/models.py | 29 +- media/__pycache__/models.cpython-312.pyc | Bin 6417 -> 5939 bytes media/models.py | 16 +- media/park/test-park/test-park_1.jpg | Bin 0 -> 825 bytes media/park/test-park/test-park_2.jpg | Bin 0 -> 825 bytes media/park/test-park/test-park_3.jpg | Bin 0 -> 825 bytes media/park/test-park/test-park_4.jpg | Bin 0 -> 825 bytes media/park/test-park/test-park_5.jpg | Bin 0 -> 825 bytes media/park/test-park/test-park_6.jpg | Bin 0 -> 825 bytes media/storage.py | 64 ++- media/submissions/photos/test_OVKudHN.gif | Bin 0 -> 35 bytes media/submissions/photos/test_yp9psr1.gif | Bin 0 -> 35 bytes media/tests.py | 246 ++++++---- moderation/mixins.py | 113 +++-- moderation/models.py | 92 ++-- moderation/tests.py | 11 +- parks/__pycache__/models.cpython-312.pyc | Bin 7907 -> 8758 bytes parks/models.py | 51 +- parks/tests.py | 122 +++-- rides/__pycache__/models.cpython-312.pyc | Bin 8781 -> 8618 bytes rides/models.py | 4 +- static/css/tailwind.css | 58 +++ 30 files changed, 944 insertions(+), 569 deletions(-) create mode 100644 history_tracking/migrations/0003_initial.py create mode 100644 media/park/test-park/test-park_1.jpg create mode 100644 media/park/test-park/test-park_2.jpg create mode 100644 media/park/test-park/test-park_3.jpg create mode 100644 media/park/test-park/test-park_4.jpg create mode 100644 media/park/test-park/test-park_5.jpg create mode 100644 media/park/test-park/test-park_6.jpg create mode 100644 media/submissions/photos/test_OVKudHN.gif create mode 100644 media/submissions/photos/test_yp9psr1.gif diff --git a/companies/__pycache__/models.cpython-312.pyc b/companies/__pycache__/models.cpython-312.pyc index 51e7bdb9885e8c4900e5b1a846e6c2298a93e758..9ed5b2d72f85733fa970dd1aa24ac3522581712c 100644 GIT binary patch literal 5616 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 ll2b37-E@EgD^wTM7X;-OH)t0fqKjvi`wAcl-bV diff --git a/companies/__pycache__/views.cpython-312.pyc b/companies/__pycache__/views.cpython-312.pyc index 6af99f10bd9d062d6f86fa45b1d1d2ff004aa6dd..48232c2ad50b103298a8ec6f850f1218cd43445c 100644 GIT binary patch literal 20800 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 L`_~MlC|&-49fE#u literal 18654 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 diff --git a/companies/models.py b/companies/models.py index cff18c0d..834d9fc4 100644 --- a/companies/models.py +++ b/companies/models.py @@ -1,83 +1,90 @@ from django.db import models -from django.contrib.contenttypes.fields import GenericRelation from django.utils.text import slugify -from simple_history.models import HistoricalRecords +from django.urls import reverse +from typing import Tuple, Optional, ClassVar, TYPE_CHECKING + +if TYPE_CHECKING: + from history_tracking.models import HistoricalSlug class Company(models.Model): name = models.CharField(max_length=255) slug = models.SlugField(max_length=255, unique=True) + website = models.URLField(blank=True) headquarters = models.CharField(max_length=255, blank=True) description = models.TextField(blank=True) - website = models.URLField(blank=True) - founded_date = models.DateField(null=True, 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) - photos = GenericRelation('media.Photo') - history = HistoricalRecords() - - # Stats fields - total_parks = models.PositiveIntegerField(default=0) - total_rides = models.PositiveIntegerField(default=0) + + objects: ClassVar[models.Manager['Company']] class Meta: - verbose_name_plural = "companies" + verbose_name_plural = 'companies' ordering = ['name'] - def __str__(self): + def __str__(self) -> str: return self.name - def save(self, *args, **kwargs): + 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): - """Get company by current or historical slug""" + def get_by_slug(cls, slug: str) -> Tuple['Company', bool]: + """Get company by slug, checking historical slugs if needed""" try: return cls.objects.get(slug=slug), False except cls.DoesNotExist: # Check historical slugs - history = cls.history.filter(slug=slug).order_by('-history_date').first() - if history: - return cls.objects.get(id=history.id), True - raise cls.DoesNotExist("No company found with this slug") + from history_tracking.models import HistoricalSlug + try: + historical = HistoricalSlug.objects.get( + content_type__model='company', + slug=slug + ) + return cls.objects.get(pk=historical.object_id), True + except (HistoricalSlug.DoesNotExist, cls.DoesNotExist): + raise cls.DoesNotExist() class Manufacturer(models.Model): name = models.CharField(max_length=255) slug = models.SlugField(max_length=255, unique=True) + website = models.URLField(blank=True) headquarters = models.CharField(max_length=255, blank=True) description = models.TextField(blank=True) - website = models.URLField(blank=True) - founded_date = models.DateField(null=True, 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) - photos = GenericRelation('media.Photo') - history = HistoricalRecords() - - # Stats fields - total_rides = models.PositiveIntegerField(default=0) - total_roller_coasters = models.PositiveIntegerField(default=0) + + objects: ClassVar[models.Manager['Manufacturer']] class Meta: ordering = ['name'] - def __str__(self): + def __str__(self) -> str: return self.name - def save(self, *args, **kwargs): + 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): - """Get manufacturer by current or historical slug""" + def get_by_slug(cls, slug: str) -> Tuple['Manufacturer', bool]: + """Get manufacturer by slug, checking historical slugs if needed""" try: return cls.objects.get(slug=slug), False except cls.DoesNotExist: # Check historical slugs - history = cls.history.filter(slug=slug).order_by('-history_date').first() - if history: - return cls.objects.get(id=history.id), True - raise cls.DoesNotExist("No manufacturer found with this slug") + from history_tracking.models import HistoricalSlug + try: + historical = HistoricalSlug.objects.get( + content_type__model='manufacturer', + slug=slug + ) + return cls.objects.get(pk=historical.object_id), True + except (HistoricalSlug.DoesNotExist, cls.DoesNotExist): + raise cls.DoesNotExist() diff --git a/companies/tests.py b/companies/tests.py index 9bc03ff6..74b14399 100644 --- a/companies/tests.py +++ b/companies/tests.py @@ -5,6 +5,8 @@ from django.contrib.contenttypes.models import ContentType from django.contrib.gis.geos import Point from django.contrib.auth import get_user_model from django.core.files.uploadedfile import SimpleUploadedFile +from django.http import HttpResponse +from typing import cast, Tuple, Optional from .models import Company, Manufacturer from location.models import Location from moderation.models import EditSubmission, PhotoSubmission @@ -13,7 +15,7 @@ from media.models import Photo User = get_user_model() class CompanyModelTests(TestCase): - def setUp(self): + def setUp(self) -> None: self.company = Company.objects.create( name='Test Company', website='http://example.com', @@ -36,7 +38,7 @@ class CompanyModelTests(TestCase): point=Point(-118.2437, 34.0522) ) - def test_company_creation(self): + def test_company_creation(self) -> None: """Test company instance creation and field values""" self.assertEqual(self.company.name, 'Test Company') self.assertEqual(self.company.website, 'http://example.com') @@ -46,22 +48,22 @@ class CompanyModelTests(TestCase): self.assertEqual(self.company.total_rides, 100) self.assertTrue(self.company.slug) - def test_company_str_representation(self): + def test_company_str_representation(self) -> None: """Test string representation of company""" self.assertEqual(str(self.company), 'Test Company') - def test_company_get_by_slug(self): + def test_company_get_by_slug(self) -> None: """Test get_by_slug class method""" company, is_historical = Company.get_by_slug(self.company.slug) self.assertEqual(company, self.company) self.assertFalse(is_historical) - def test_company_get_by_invalid_slug(self): + def test_company_get_by_invalid_slug(self) -> None: """Test get_by_slug with invalid slug""" with self.assertRaises(Company.DoesNotExist): Company.get_by_slug('invalid-slug') - def test_company_stats(self): + def test_company_stats(self) -> None: """Test company statistics fields""" self.company.total_parks = 10 self.company.total_rides = 200 @@ -72,7 +74,7 @@ class CompanyModelTests(TestCase): self.assertEqual(company.total_rides, 200) class ManufacturerModelTests(TestCase): - def setUp(self): + def setUp(self) -> None: self.manufacturer = Manufacturer.objects.create( name='Test Manufacturer', website='http://example.com', @@ -95,7 +97,7 @@ class ManufacturerModelTests(TestCase): point=Point(-118.2437, 34.0522) ) - def test_manufacturer_creation(self): + def test_manufacturer_creation(self) -> None: """Test manufacturer instance creation and field values""" self.assertEqual(self.manufacturer.name, 'Test Manufacturer') self.assertEqual(self.manufacturer.website, 'http://example.com') @@ -105,22 +107,22 @@ class ManufacturerModelTests(TestCase): self.assertEqual(self.manufacturer.total_roller_coasters, 20) self.assertTrue(self.manufacturer.slug) - def test_manufacturer_str_representation(self): + def test_manufacturer_str_representation(self) -> None: """Test string representation of manufacturer""" self.assertEqual(str(self.manufacturer), 'Test Manufacturer') - def test_manufacturer_get_by_slug(self): + def test_manufacturer_get_by_slug(self) -> None: """Test get_by_slug class method""" manufacturer, is_historical = Manufacturer.get_by_slug(self.manufacturer.slug) self.assertEqual(manufacturer, self.manufacturer) self.assertFalse(is_historical) - def test_manufacturer_get_by_invalid_slug(self): + def test_manufacturer_get_by_invalid_slug(self) -> None: """Test get_by_slug with invalid slug""" with self.assertRaises(Manufacturer.DoesNotExist): Manufacturer.get_by_slug('invalid-slug') - def test_manufacturer_stats(self): + def test_manufacturer_stats(self) -> None: """Test manufacturer statistics fields""" self.manufacturer.total_rides = 100 self.manufacturer.total_roller_coasters = 40 @@ -131,7 +133,7 @@ class ManufacturerModelTests(TestCase): self.assertEqual(manufacturer.total_roller_coasters, 40) class CompanyViewTests(TestCase): - def setUp(self): + def setUp(self) -> None: self.client = Client() self.user = User.objects.create_user( username='testuser', @@ -164,13 +166,13 @@ class CompanyViewTests(TestCase): point=Point(-118.2437, 34.0522) ) - def test_company_list_view(self): + def test_company_list_view(self) -> None: """Test company list view""" response = self.client.get(reverse('companies:company_list')) self.assertEqual(response.status_code, 200) self.assertContains(response, self.company.name) - def test_company_list_view_with_search(self): + def test_company_list_view_with_search(self) -> None: """Test company list view with search""" response = self.client.get(reverse('companies:company_list') + '?search=Test') self.assertEqual(response.status_code, 200) @@ -180,7 +182,7 @@ class CompanyViewTests(TestCase): self.assertEqual(response.status_code, 200) self.assertNotContains(response, self.company.name) - def test_company_list_view_with_country_filter(self): + def test_company_list_view_with_country_filter(self) -> None: """Test company list view with country filter""" response = self.client.get(reverse('companies:company_list') + '?country=Test Country') self.assertEqual(response.status_code, 200) @@ -190,7 +192,7 @@ class CompanyViewTests(TestCase): self.assertEqual(response.status_code, 200) self.assertNotContains(response, self.company.name) - def test_company_detail_view(self): + def test_company_detail_view(self) -> None: """Test company detail view""" response = self.client.get( reverse('companies:company_detail', kwargs={'slug': self.company.slug}) @@ -200,25 +202,25 @@ class CompanyViewTests(TestCase): self.assertContains(response, self.company.website) self.assertContains(response, self.company.headquarters) - def test_company_detail_view_invalid_slug(self): + def test_company_detail_view_invalid_slug(self) -> None: """Test company detail view with invalid slug""" response = self.client.get( reverse('companies:company_detail', kwargs={'slug': 'invalid-slug'}) ) self.assertEqual(response.status_code, 404) - def test_company_create_view_unauthenticated(self): + def test_company_create_view_unauthenticated(self) -> None: """Test company create view when not logged in""" response = self.client.get(reverse('companies:company_create')) self.assertEqual(response.status_code, 302) # Redirects to login - def test_company_create_view_authenticated(self): + def test_company_create_view_authenticated(self) -> None: """Test company create view when logged in""" self.client.login(username='testuser', password='testpass123') response = self.client.get(reverse('companies:company_create')) self.assertEqual(response.status_code, 200) - def test_company_create_submission_regular_user(self): + def test_company_create_submission_regular_user(self) -> None: """Test creating a company submission as regular user""" self.client.login(username='testuser', password='testpass123') data = { @@ -237,7 +239,7 @@ class CompanyViewTests(TestCase): status='NEW' ).exists()) - def test_company_create_submission_moderator(self): + def test_company_create_submission_moderator(self) -> None: """Test creating a company submission as moderator""" self.client.login(username='moderator', password='modpass123') data = { @@ -257,7 +259,7 @@ class CompanyViewTests(TestCase): self.assertEqual(submission.status, 'APPROVED') self.assertEqual(submission.handled_by, self.moderator) - def test_company_photo_submission(self): + def test_company_photo_submission(self) -> None: """Test photo submission for company""" self.client.login(username='testuser', password='testpass123') image_content = 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;' @@ -267,19 +269,19 @@ class CompanyViewTests(TestCase): 'caption': 'Test Photo', 'date_taken': '2024-01-01' } - response = self.client.post( + response = cast(HttpResponse, self.client.post( reverse('companies:company_detail', kwargs={'slug': self.company.slug}), data, HTTP_X_REQUESTED_WITH='XMLHttpRequest' # Simulate AJAX request - ) + )) self.assertEqual(response.status_code, 200) self.assertTrue(PhotoSubmission.objects.filter( content_type=ContentType.objects.get_for_model(Company), - object_id=self.company.id + object_id=self.company.pk ).exists()) class ManufacturerViewTests(TestCase): - def setUp(self): + def setUp(self) -> None: self.client = Client() self.user = User.objects.create_user( username='testuser', @@ -312,13 +314,13 @@ class ManufacturerViewTests(TestCase): point=Point(-118.2437, 34.0522) ) - def test_manufacturer_list_view(self): + def test_manufacturer_list_view(self) -> None: """Test manufacturer list view""" response = self.client.get(reverse('companies:manufacturer_list')) self.assertEqual(response.status_code, 200) self.assertContains(response, self.manufacturer.name) - def test_manufacturer_list_view_with_search(self): + def test_manufacturer_list_view_with_search(self) -> None: """Test manufacturer list view with search""" response = self.client.get(reverse('companies:manufacturer_list') + '?search=Test') self.assertEqual(response.status_code, 200) @@ -328,7 +330,7 @@ class ManufacturerViewTests(TestCase): self.assertEqual(response.status_code, 200) self.assertNotContains(response, self.manufacturer.name) - def test_manufacturer_list_view_with_country_filter(self): + def test_manufacturer_list_view_with_country_filter(self) -> None: """Test manufacturer list view with country filter""" response = self.client.get(reverse('companies:manufacturer_list') + '?country=Test Country') self.assertEqual(response.status_code, 200) @@ -338,7 +340,7 @@ class ManufacturerViewTests(TestCase): self.assertEqual(response.status_code, 200) self.assertNotContains(response, self.manufacturer.name) - def test_manufacturer_detail_view(self): + def test_manufacturer_detail_view(self) -> None: """Test manufacturer detail view""" response = self.client.get( reverse('companies:manufacturer_detail', kwargs={'slug': self.manufacturer.slug}) @@ -348,25 +350,25 @@ class ManufacturerViewTests(TestCase): self.assertContains(response, self.manufacturer.website) self.assertContains(response, self.manufacturer.headquarters) - def test_manufacturer_detail_view_invalid_slug(self): + def test_manufacturer_detail_view_invalid_slug(self) -> None: """Test manufacturer detail view with invalid slug""" response = self.client.get( reverse('companies:manufacturer_detail', kwargs={'slug': 'invalid-slug'}) ) self.assertEqual(response.status_code, 404) - def test_manufacturer_create_view_unauthenticated(self): + def test_manufacturer_create_view_unauthenticated(self) -> None: """Test manufacturer create view when not logged in""" response = self.client.get(reverse('companies:manufacturer_create')) self.assertEqual(response.status_code, 302) # Redirects to login - def test_manufacturer_create_view_authenticated(self): + def test_manufacturer_create_view_authenticated(self) -> None: """Test manufacturer create view when logged in""" self.client.login(username='testuser', password='testpass123') response = self.client.get(reverse('companies:manufacturer_create')) self.assertEqual(response.status_code, 200) - def test_manufacturer_create_submission_regular_user(self): + def test_manufacturer_create_submission_regular_user(self) -> None: """Test creating a manufacturer submission as regular user""" self.client.login(username='testuser', password='testpass123') data = { @@ -385,7 +387,7 @@ class ManufacturerViewTests(TestCase): status='NEW' ).exists()) - def test_manufacturer_create_submission_moderator(self): + def test_manufacturer_create_submission_moderator(self) -> None: """Test creating a manufacturer submission as moderator""" self.client.login(username='moderator', password='modpass123') data = { @@ -405,7 +407,7 @@ class ManufacturerViewTests(TestCase): self.assertEqual(submission.status, 'APPROVED') self.assertEqual(submission.handled_by, self.moderator) - def test_manufacturer_photo_submission(self): + def test_manufacturer_photo_submission(self) -> None: """Test photo submission for manufacturer""" self.client.login(username='testuser', password='testpass123') image_content = 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;' @@ -415,13 +417,13 @@ class ManufacturerViewTests(TestCase): 'caption': 'Test Photo', 'date_taken': '2024-01-01' } - response = self.client.post( + response = cast(HttpResponse, self.client.post( reverse('companies:manufacturer_detail', kwargs={'slug': self.manufacturer.slug}), data, HTTP_X_REQUESTED_WITH='XMLHttpRequest' # Simulate AJAX request - ) + )) self.assertEqual(response.status_code, 200) self.assertTrue(PhotoSubmission.objects.filter( content_type=ContentType.objects.get_for_model(Manufacturer), - object_id=self.manufacturer.id + object_id=self.manufacturer.pk ).exists()) diff --git a/companies/views.py b/companies/views.py index 01ee2789..84708c8d 100644 --- a/companies/views.py +++ b/companies/views.py @@ -1,11 +1,13 @@ +from typing import Any, Optional, Tuple, Type, cast, Union, Dict, Callable from django.views.generic import DetailView, ListView, CreateView, UpdateView from django.shortcuts import get_object_or_404 from django.urls import reverse from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.contenttypes.models import ContentType from django.contrib import messages -from django.http import HttpResponseRedirect, Http404, JsonResponse -from django.db.models import Count, Sum, Q +from django.http import HttpResponseRedirect, Http404, JsonResponse, HttpResponse +from django.db.models import Count, Sum, Q, QuerySet, Model +from django.contrib.auth import get_user_model from .models import Company, Manufacturer from .forms import CompanyForm, ManufacturerForm from rides.models import Ride @@ -15,302 +17,349 @@ from core.views import SlugRedirectMixin from moderation.mixins import EditSubmissionMixin, PhotoSubmissionMixin, HistoryMixin from moderation.models import EditSubmission +User = get_user_model() + +ModelType = Union[Type[Company], Type[Manufacturer]] + +def get_company_parks(company: Company) -> QuerySet[Park]: + """Get parks owned by a company with related data.""" + return Park.objects.filter( + owner=company + ).select_related('owner') + +def get_company_ride_count(parks: QuerySet[Park]) -> int: + """Get total number of rides across all parks.""" + return Ride.objects.filter(park__in=parks).count() + +def get_manufacturer_rides(manufacturer: Manufacturer) -> QuerySet[Ride]: + """Get rides made by a manufacturer with related data.""" + return Ride.objects.filter( + manufacturer=manufacturer + ).select_related('park', 'coaster_stats') + +def get_manufacturer_stats(rides: QuerySet[Ride]) -> Dict[str, int]: + """Get statistics for manufacturer rides.""" + return { + 'coaster_count': rides.filter(category='ROLLER_COASTER').count(), + 'parks_count': rides.values('park').distinct().count() + } + +def handle_submission_post( + request: Any, + handle_photo_submission: Callable[[Any], HttpResponse], + super_post: Callable[..., HttpResponse], + *args: Any, + **kwargs: Any +) -> HttpResponse: + """Handle POST requests for photos and edits.""" + if request.FILES: + # Handle photo submission + return handle_photo_submission(request) + # Handle edit submission + return super_post(request, *args, **kwargs) + # List Views class CompanyListView(ListView): - model = Company - template_name = 'companies/company_list.html' - context_object_name = 'companies' + model: Type[Company] = Company + template_name = "companies/company_list.html" + context_object_name = "companies" paginate_by = 12 - def get_queryset(self): - queryset = Company.objects.all() - - # Filter by country if specified - country = self.request.GET.get('country') - if country: + def get_queryset(self) -> QuerySet[Company]: + queryset = self.model.objects.all() + + if country := self.request.GET.get("country"): # Get companies that have locations in the specified country company_ids = Location.objects.filter( content_type=ContentType.objects.get_for_model(Company), - country__iexact=country - ).values_list('object_id', flat=True) - queryset = queryset.filter(id__in=company_ids) - - # Search by name if specified - search = self.request.GET.get('search') - if search: - queryset = queryset.filter(name__icontains=search) - - return queryset.order_by('name') + country__iexact=country, + ).values_list("object_id", flat=True) + queryset = queryset.filter(pk__in=company_ids) - def get_context_data(self, **kwargs): + if search := self.request.GET.get("search"): + queryset = queryset.filter(name__icontains=search) + + return queryset.order_by("name") + + def get_context_data(self, **kwargs: Any) -> dict[str, Any]: context = super().get_context_data(**kwargs) # Add filter values to context - context['country'] = self.request.GET.get('country', '') - context['search'] = self.request.GET.get('search', '') + context["country"] = self.request.GET.get("country", "") + context["search"] = self.request.GET.get("search", "") return context + class ManufacturerListView(ListView): - model = Manufacturer - template_name = 'companies/manufacturer_list.html' - context_object_name = 'manufacturers' + model: Type[Manufacturer] = Manufacturer + template_name = "companies/manufacturer_list.html" + context_object_name = "manufacturers" paginate_by = 12 - def get_queryset(self): - queryset = Manufacturer.objects.all() - - # Filter by country if specified - country = self.request.GET.get('country') - if country: + def get_queryset(self) -> QuerySet[Manufacturer]: + queryset = self.model.objects.all() + + if country := self.request.GET.get("country"): # Get manufacturers that have locations in the specified country manufacturer_ids = Location.objects.filter( content_type=ContentType.objects.get_for_model(Manufacturer), - country__iexact=country - ).values_list('object_id', flat=True) - queryset = queryset.filter(id__in=manufacturer_ids) - - # Search by name if specified - search = self.request.GET.get('search') - if search: - queryset = queryset.filter(name__icontains=search) - - return queryset.order_by('name') + country__iexact=country, + ).values_list("object_id", flat=True) + queryset = queryset.filter(pk__in=manufacturer_ids) - def get_context_data(self, **kwargs): + if search := self.request.GET.get("search"): + queryset = queryset.filter(name__icontains=search) + + return queryset.order_by("name") + + def get_context_data(self, **kwargs: Any) -> dict[str, Any]: context = super().get_context_data(**kwargs) # Add stats for filtering - context['total_manufacturers'] = self.model.objects.count() - context['total_rides'] = Ride.objects.filter( - manufacturer__isnull=False - ).count() - context['total_roller_coasters'] = Ride.objects.filter( - manufacturer__isnull=False, - category='ROLLER_COASTER' + context["total_manufacturers"] = self.model.objects.count() + context["total_rides"] = Ride.objects.filter(manufacturer__isnull=False).count() + context["total_roller_coasters"] = Ride.objects.filter( + manufacturer__isnull=False, category="ROLLER_COASTER" ).count() # Add filter values to context - context['country'] = self.request.GET.get('country', '') - context['search'] = self.request.GET.get('search', '') + context["country"] = self.request.GET.get("country", "") + context["search"] = self.request.GET.get("search", "") return context + # Detail Views class CompanyDetailView(SlugRedirectMixin, EditSubmissionMixin, PhotoSubmissionMixin, HistoryMixin, DetailView): - model = Company + model: Type[Company] = Company template_name = 'companies/company_detail.html' context_object_name = 'company' - def get_object(self, queryset=None): + def get_object(self, queryset: Optional[QuerySet[Company]] = None) -> Company: if queryset is None: queryset = self.get_queryset() slug = self.kwargs.get(self.slug_url_kwarg) try: # Try to get by current or historical slug - return self.model.get_by_slug(slug)[0] - except self.model.DoesNotExist: - raise Http404(f"No {self.model._meta.verbose_name} found matching the query") + model = cast(Type[Company], self.model) + obj, _ = model.get_by_slug(slug) + return obj + except model.DoesNotExist as e: + raise Http404(f"No {model._meta.verbose_name} found matching the query") from e - def get_context_data(self, **kwargs): + def get_context_data(self, **kwargs: Any) -> dict[str, Any]: context = super().get_context_data(**kwargs) - parks = Park.objects.filter( - owner=self.object - ).select_related('owner') + company = cast(Company, self.object) + parks = get_company_parks(company) context['parks'] = parks - context['total_rides'] = Ride.objects.filter(park__in=parks).count() - + context['total_rides'] = get_company_ride_count(parks) return context - def get_redirect_url_pattern(self): + def get_redirect_url_pattern(self) -> str: return 'companies:company_detail' - def post(self, request, *args, **kwargs): - """Handle POST requests for photos and edits""" - if request.FILES: - # Handle photo submission - return self.handle_photo_submission(request) - # Handle edit submission - return super().post(request, *args, **kwargs) + def post(self, request: Any, *args: Any, **kwargs: Any) -> HttpResponse: + """Handle POST requests for photos and edits.""" + return handle_submission_post( + request, + self.handle_photo_submission, + super().post, + *args, + **kwargs + ) class ManufacturerDetailView(SlugRedirectMixin, EditSubmissionMixin, PhotoSubmissionMixin, HistoryMixin, DetailView): - model = Manufacturer + model: Type[Manufacturer] = Manufacturer template_name = 'companies/manufacturer_detail.html' context_object_name = 'manufacturer' - def get_object(self, queryset=None): + def get_object(self, queryset: Optional[QuerySet[Manufacturer]] = None) -> Manufacturer: if queryset is None: queryset = self.get_queryset() slug = self.kwargs.get(self.slug_url_kwarg) try: # Try to get by current or historical slug - return self.model.get_by_slug(slug)[0] - except self.model.DoesNotExist: - raise Http404(f"No {self.model._meta.verbose_name} found matching the query") + model = cast(Type[Manufacturer], self.model) + obj, _ = model.get_by_slug(slug) + return obj + except model.DoesNotExist as e: + raise Http404(f"No {model._meta.verbose_name} found matching the query") from e - def get_context_data(self, **kwargs): + def get_context_data(self, **kwargs: Any) -> dict[str, Any]: context = super().get_context_data(**kwargs) - rides = Ride.objects.filter( - manufacturer=self.object - ).select_related('park', 'coaster_stats') + manufacturer = cast(Manufacturer, self.object) + rides = get_manufacturer_rides(manufacturer) context['rides'] = rides - context['coaster_count'] = rides.filter(category='ROLLER_COASTER').count() - context['parks_count'] = rides.values('park').distinct().count() - + context.update(get_manufacturer_stats(rides)) return context - def get_redirect_url_pattern(self): + def get_redirect_url_pattern(self) -> str: return 'companies:manufacturer_detail' - def post(self, request, *args, **kwargs): - """Handle POST requests for photos and edits""" - if request.FILES: - # Handle photo submission - return self.handle_photo_submission(request) - # Handle edit submission - return super().post(request, *args, **kwargs) + def post(self, request: Any, *args: Any, **kwargs: Any) -> HttpResponse: + """Handle POST requests for photos and edits.""" + return handle_submission_post( + request, + self.handle_photo_submission, + super().post, + *args, + **kwargs + ) + + +def _handle_submission( + request: Any, form: Any, model: ModelType, success_url: str +) -> HttpResponseRedirect: + """Helper method to handle form submissions""" + cleaned_data = form.cleaned_data.copy() + submission = EditSubmission.objects.create( + user=request.user, + content_type=ContentType.objects.get_for_model(model), + submission_type="CREATE", + changes=cleaned_data, + reason=request.POST.get("reason", ""), + source=request.POST.get("source", ""), + ) + + # Get user role safely + user_role = getattr(request.user, "role", None) + + # If user is moderator or above, auto-approve + if user_role in ["MODERATOR", "ADMIN", "SUPERUSER"]: + obj = form.save() + submission.object_id = obj.pk + submission.status = "APPROVED" + submission.handled_by = request.user + submission.save() + messages.success(request, f'Successfully created {getattr(obj, "name", "")}') + return HttpResponseRedirect(success_url) + + messages.success(request, "Your submission has been sent for review") + return HttpResponseRedirect(reverse(f"companies:{model.__name__.lower()}_list")) + # Create Views class CompanyCreateView(LoginRequiredMixin, CreateView): - model = Company + model: Type[Company] = Company form_class = CompanyForm - template_name = 'companies/company_form.html' + template_name = "companies/company_form.html" + object: Optional[Company] - def form_valid(self, form): - cleaned_data = form.cleaned_data.copy() - - # Create submission record - submission = EditSubmission.objects.create( - user=self.request.user, - content_type=ContentType.objects.get_for_model(Company), - submission_type='CREATE', - changes=cleaned_data, - reason=self.request.POST.get('reason', ''), - source=self.request.POST.get('source', '') + def form_valid(self, form: CompanyForm) -> HttpResponseRedirect: + success_url = reverse( + "companies:company_detail", kwargs={"slug": form.instance.slug} ) - - # If user is moderator or above, auto-approve - if self.request.user.role in ['MODERATOR', 'ADMIN', 'SUPERUSER']: - self.object = form.save() - submission.object_id = self.object.id - submission.status = 'APPROVED' - submission.handled_by = self.request.user - submission.save() - messages.success(self.request, f'Successfully created {self.object.name}') - return HttpResponseRedirect(self.get_success_url()) - - messages.success(self.request, 'Your company submission has been sent for review') - return HttpResponseRedirect(reverse('companies:company_list')) + return _handle_submission(self.request, form, self.model, success_url) + + def get_success_url(self) -> str: + if self.object is None: + return reverse("companies:company_list") + return reverse("companies:company_detail", kwargs={"slug": self.object.slug}) - def get_success_url(self): - return reverse('companies:company_detail', kwargs={'slug': self.object.slug}) class ManufacturerCreateView(LoginRequiredMixin, CreateView): - model = Manufacturer + model: Type[Manufacturer] = Manufacturer form_class = ManufacturerForm - template_name = 'companies/manufacturer_form.html' + template_name = "companies/manufacturer_form.html" + object: Optional[Manufacturer] - def form_valid(self, form): - cleaned_data = form.cleaned_data.copy() - - # Create submission record - submission = EditSubmission.objects.create( - user=self.request.user, - content_type=ContentType.objects.get_for_model(Manufacturer), - submission_type='CREATE', - changes=cleaned_data, - reason=self.request.POST.get('reason', ''), - source=self.request.POST.get('source', '') + def form_valid(self, form: ManufacturerForm) -> HttpResponseRedirect: + success_url = reverse( + "companies:manufacturer_detail", kwargs={"slug": form.instance.slug} ) - - # If user is moderator or above, auto-approve - if self.request.user.role in ['MODERATOR', 'ADMIN', 'SUPERUSER']: - self.object = form.save() - submission.object_id = self.object.id - submission.status = 'APPROVED' - submission.handled_by = self.request.user - submission.save() - messages.success(self.request, f'Successfully created {self.object.name}') - return HttpResponseRedirect(self.get_success_url()) - - messages.success(self.request, 'Your manufacturer submission has been sent for review') - return HttpResponseRedirect(reverse('companies:manufacturer_list')) + return _handle_submission(self.request, form, self.model, success_url) + + def get_success_url(self) -> str: + if self.object is None: + return reverse("companies:manufacturer_list") + return reverse( + "companies:manufacturer_detail", kwargs={"slug": self.object.slug} + ) + + +def _handle_update( + request: Any, form: Any, obj: Union[Company, Manufacturer], model: ModelType +) -> HttpResponseRedirect: + """Helper method to handle update submissions""" + cleaned_data = form.cleaned_data.copy() + submission = EditSubmission.objects.create( + user=request.user, + content_type=ContentType.objects.get_for_model(model), + object_id=obj.pk, + submission_type="EDIT", + changes=cleaned_data, + reason=request.POST.get("reason", ""), + source=request.POST.get("source", ""), + ) + + # Get user role safely + user_role = getattr(request.user, "role", None) + + # If user is moderator or above, auto-approve + if user_role in ["MODERATOR", "ADMIN", "SUPERUSER"]: + obj = form.save() + submission.status = "APPROVED" + submission.handled_by = request.user + submission.save() + messages.success(request, f'Successfully updated {getattr(obj, "name", "")}') + return HttpResponseRedirect( + reverse( + f"companies:{model.__name__.lower()}_detail", + kwargs={"slug": getattr(obj, "slug", "")}, + ) + ) + + messages.success( + request, f'Your changes to {getattr(obj, "name", "")} have been sent for review' + ) + return HttpResponseRedirect( + reverse( + f"companies:{model.__name__.lower()}_detail", + kwargs={"slug": getattr(obj, "slug", "")}, + ) + ) - def get_success_url(self): - return reverse('companies:manufacturer_detail', kwargs={'slug': self.object.slug}) # Update Views class CompanyUpdateView(LoginRequiredMixin, UpdateView): - model = Company + model: Type[Company] = Company form_class = CompanyForm - template_name = 'companies/company_form.html' + template_name = "companies/company_form.html" + object: Optional[Company] - def get_context_data(self, **kwargs): + def get_context_data(self, **kwargs: Any) -> dict[str, Any]: context = super().get_context_data(**kwargs) - context['is_edit'] = True + context["is_edit"] = True return context - def form_valid(self, form): - cleaned_data = form.cleaned_data.copy() + def form_valid(self, form: CompanyForm) -> HttpResponseRedirect: + if self.object is None: + return HttpResponseRedirect(reverse("companies:company_list")) + return _handle_update(self.request, form, self.object, self.model) - # Create submission record - submission = EditSubmission.objects.create( - user=self.request.user, - content_type=ContentType.objects.get_for_model(Company), - object_id=self.object.id, - submission_type='EDIT', - changes=cleaned_data, - reason=self.request.POST.get('reason', ''), - source=self.request.POST.get('source', '') - ) - - # If user is moderator or above, auto-approve - if self.request.user.role in ['MODERATOR', 'ADMIN', 'SUPERUSER']: - self.object = form.save() - submission.status = 'APPROVED' - submission.handled_by = self.request.user - submission.save() - messages.success(self.request, f'Successfully updated {self.object.name}') - return HttpResponseRedirect(self.get_success_url()) - - messages.success(self.request, f'Your changes to {self.object.name} have been sent for review') - return HttpResponseRedirect(reverse('companies:company_detail', kwargs={'slug': self.object.slug})) + def get_success_url(self) -> str: + if self.object is None: + return reverse("companies:company_list") + return reverse("companies:company_detail", kwargs={"slug": self.object.slug}) - def get_success_url(self): - return reverse('companies:company_detail', kwargs={'slug': self.object.slug}) class ManufacturerUpdateView(LoginRequiredMixin, UpdateView): - model = Manufacturer + model: Type[Manufacturer] = Manufacturer form_class = ManufacturerForm - template_name = 'companies/manufacturer_form.html' + template_name = "companies/manufacturer_form.html" + object: Optional[Manufacturer] - def get_context_data(self, **kwargs): + def get_context_data(self, **kwargs: Any) -> dict[str, Any]: context = super().get_context_data(**kwargs) - context['is_edit'] = True + context["is_edit"] = True return context - def form_valid(self, form): - cleaned_data = form.cleaned_data.copy() + def form_valid(self, form: ManufacturerForm) -> HttpResponseRedirect: + if self.object is None: + return HttpResponseRedirect(reverse("companies:manufacturer_list")) + return _handle_update(self.request, form, self.object, self.model) - # Create submission record - submission = EditSubmission.objects.create( - user=self.request.user, - content_type=ContentType.objects.get_for_model(Manufacturer), - object_id=self.object.id, - submission_type='EDIT', - changes=cleaned_data, - reason=self.request.POST.get('reason', ''), - source=self.request.POST.get('source', '') + def get_success_url(self) -> str: + if self.object is None: + return reverse("companies:manufacturer_list") + return reverse( + "companies:manufacturer_detail", kwargs={"slug": self.object.slug} ) - - # If user is moderator or above, auto-approve - if self.request.user.role in ['MODERATOR', 'ADMIN', 'SUPERUSER']: - self.object = form.save() - submission.status = 'APPROVED' - submission.handled_by = self.request.user - submission.save() - messages.success(self.request, f'Successfully updated {self.object.name}') - return HttpResponseRedirect(self.get_success_url()) - - messages.success(self.request, f'Your changes to {self.object.name} have been sent for review') - return HttpResponseRedirect(reverse('companies:manufacturer_detail', kwargs={'slug': self.object.slug})) - - def get_success_url(self): - return reverse('companies:manufacturer_detail', kwargs={'slug': self.object.slug}) diff --git a/core/__pycache__/views.cpython-312.pyc b/core/__pycache__/views.cpython-312.pyc index e8fdbee30aac018ed0360e6b5c6a6a76fde760af..3b33e25e252b53a385664871aedb7fcb0ed53b4c 100644 GIT binary patch literal 3474 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 diff --git a/core/views.py b/core/views.py index 2c0175d8..fe5c224c 100644 --- a/core/views.py +++ b/core/views.py @@ -1,23 +1,31 @@ +from typing import Any, Dict, Optional, Type, cast from django.shortcuts import redirect from django.urls import reverse from django.views.generic import DetailView +from django.views import View +from django.http import HttpRequest, HttpResponse +from django.db.models import Model -class SlugRedirectMixin: +class SlugRedirectMixin(View): """ Mixin that handles redirects for old slugs. Requires the model to inherit from SluggedModel and view to inherit from DetailView. """ - def dispatch(self, request, *args, **kwargs): + model: Optional[Type[Model]] = None + slug_url_kwarg: str = 'slug' + object: Optional[Model] = None + + def dispatch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: # Only apply slug redirect logic to DetailViews if not isinstance(self, DetailView): return super().dispatch(request, *args, **kwargs) # Get the object using current or historical slug try: - self.object = self.get_object() + self.object = self.get_object() # type: ignore # Check if we used an old slug current_slug = kwargs.get(self.slug_url_kwarg) - if current_slug and current_slug != self.object.slug: + if current_slug and current_slug != getattr(self.object, 'slug', None): # Get the URL pattern name from the view url_pattern = self.get_redirect_url_pattern() # Build kwargs for reverse() @@ -28,10 +36,13 @@ class SlugRedirectMixin: permanent=True ) return super().dispatch(request, *args, **kwargs) - except (self.model.DoesNotExist, AttributeError): + except (AttributeError, Exception) as e: # type: ignore + if self.model and hasattr(self.model, 'DoesNotExist'): + if isinstance(e, self.model.DoesNotExist): # type: ignore + return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs) - def get_redirect_url_pattern(self): + def get_redirect_url_pattern(self) -> str: """ Get the URL pattern name for redirects. Should be overridden by subclasses. @@ -40,9 +51,11 @@ class SlugRedirectMixin: "Subclasses must implement get_redirect_url_pattern()" ) - def get_redirect_url_kwargs(self): + def get_redirect_url_kwargs(self) -> Dict[str, Any]: """ Get the kwargs for reverse() when redirecting. Should be overridden by subclasses if they need custom kwargs. """ - return {self.slug_url_kwarg: self.object.slug} + if not self.object: + return {} + return {self.slug_url_kwarg: getattr(self.object, 'slug', '')} diff --git a/history_tracking/migrations/0003_initial.py b/history_tracking/migrations/0003_initial.py new file mode 100644 index 00000000..50ca1d12 --- /dev/null +++ b/history_tracking/migrations/0003_initial.py @@ -0,0 +1,54 @@ +# 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")}, + }, + ), + ] diff --git a/history_tracking/models.py b/history_tracking/models.py index eccb2a17..9d89474e 100644 --- a/history_tracking/models.py +++ b/history_tracking/models.py @@ -1,14 +1,39 @@ # history_tracking/models.py from django.db import models +from django.contrib.contenttypes.models import ContentType +from django.contrib.contenttypes.fields import GenericForeignKey from simple_history.models import HistoricalRecords from .mixins import HistoricalChangeMixin +from typing import Any, Type, TypeVar, cast +T = TypeVar('T', bound=models.Model) class HistoricalModel(models.Model): """Abstract base class for models with history tracking""" + history: HistoricalRecords = HistoricalRecords(inherit=True) + class Meta: abstract = True @property - def _history_model(self): - return self.history.model + def _history_model(self) -> Type[T]: + """Get the history model class""" + return cast(Type[T], self.history.model) # type: ignore + +class HistoricalSlug(models.Model): + """Track historical slugs for models""" + content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) + object_id = models.PositiveIntegerField() + content_object = GenericForeignKey('content_type', 'object_id') + slug = models.SlugField(max_length=255) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + unique_together = ('content_type', 'slug') + indexes = [ + models.Index(fields=['content_type', 'object_id']), + models.Index(fields=['slug']), + ] + + def __str__(self) -> str: + return f"{self.content_type} - {self.object_id} - {self.slug}" diff --git a/media/__pycache__/models.cpython-312.pyc b/media/__pycache__/models.cpython-312.pyc index 1bf2c0cc5bc3c5cf22869cfa408c7c68e45e406a..5bed2bbe77dbd3e1a63fe5a274f555388142c175 100644 GIT binary patch delta 1062 zcmYLHU1-!;6waN=Bp6*FAm91>&f)yryAz#C@9SJH zE#bGb{lkjc_pDb-FeY{+UQD)A4xFsMIA4Q_yoaCy1X>8Ay-ygE1?XI%{Njl3)YN~ z8F~g3tQ)4zUhx7i%r?Acz?*>?TyJr~Qsd{LpTaWnp^U1oK@kO%qO2BW!64~LZ)r75 zWASF-EIS;++>+&UbJ=lu(^}`S6G^^?Al{`GGdQS}C(m<9oKPEUd@ z;OQx5h)3x?`T>lGS>Ub}aNDF7bOwje8MoVm?ea!oI<|P9zOB#*a9DVmg_2I{C}qp1 zd0@fMsLqNoV<=H%M&z^NSi~P9lG?CZf#WrG7Ro+%mwme4?zn(zntlMLxsi+3Tlr?5 z6z{TAOz#Zl`V(wU82KOBsQ4{!u=&p4`HRW8_+cuZc6_r1&bkHHvHM|k)le>=)C-Xr zrfrMG&NWV z8|k=V-@3*Z#hb$0ewtk$gDGMhCHO_WEDlZ59ifll6hhcDd&Pp9!(AJyWNVg-Z;AgA zgxML-bGg-wg9{>GnqXJ2*sF}Q^RhQDu$|H=msl-c-}@;Lh_`$0nW dMKThvk{`+Xbg;(Q-al)1=Kq@iERh~n{0c5c37!A| delta 1471 zcmYjQUrZZy9KT<$cfDR)SSf9#o7gcXlz(Ld7B+N>MKoD)AkLV$?L2xv;EtoMzdIbm zBAC5w(`1m}!$`P#S)_~4VTB+L*WO#FSXFm?~0@893||7~`y zp0D{e6p}gieT>_tR_cDLkvzyl#)&F@5se5veImkp3@^~r9;5z6YnA`O z`{Xc&5vSh-PPU6BQ4(9>i(l zqEr&rIn7t{t#c)5oqOb6^p|)|nrY=YlC}?@a-5w!3S4?#(4wsQOR~{)WI3ir^D%$K zu`IHiOD_4>0oMX=+IVf1-aV{00=J73s)r0fuEGDN|Oe?@3nM_jM zdn3@x_*zi5B3kj@TzV#Q@ly|HS7&!(ZIxKtZZxqIZK*_CcI)B~)2rz{fs42Ab9`Jp z5IJA)OMGW)&&LV%dlDCEDi2ob&MseJAp)TXE%#b>L8%wTSFNezm<<1TM@!WQ#Zy9Cj>q1xa^^NzoYctzYW>4g5A}g0xI@Z*3 zaP5ojK-adnYoFE2M5PfqxMirP)wB>Y?+YH2Prkap)RHs3uy#^|A3qYIPB=F zaC2Z3|neJl+9#m;PDT*blcs!T{G8 zIR4DEN``f8YUC=&l4`O)$Gx$)3I!9Z`KnQ+J&}GiLCr{aeGXpx!P}P^9M236y-gbE zMkFx-zXp*2k^mzN9Nw}C83WM^0B2e4mj@0?cS4GiGgZsVVbfF;HYby5-QmCKZm&8I z7|OZLj{1*Kb$gBUe)LCyv@kUth_#|iREu3W|2Y$h+oL str: if identifier is None: identifier = obj.pk # Use pk instead of id as it's guaranteed to exist - # Get the next available number for this object - existing_photos = Photo.objects.filter( - content_type=photo.content_type, - object_id=photo.object_id - ).count() - next_number = existing_photos + 1 - - # Create normalized filename - ext = os.path.splitext(filename)[1].lower() or '.jpg' # Default to .jpg if no extension - new_filename = f"{identifier}_{next_number}{ext}" + # Create normalized filename - always use .jpg extension + base_filename = f"{identifier}.jpg" # If it's a ride photo, store it under the park's directory if content_type == 'ride': ride = cast(Ride, obj) - return f"park/{ride.park.slug}/{identifier}/{new_filename}" + return f"park/{ride.park.slug}/{identifier}/{base_filename}" # For park photos, store directly in park directory - return f"park/{identifier}/{new_filename}" + return f"park/{identifier}/{base_filename}" class Photo(models.Model): """Generic photo model that can be attached to any model""" diff --git a/media/park/test-park/test-park_1.jpg b/media/park/test-park/test-park_1.jpg new file mode 100644 index 0000000000000000000000000000000000000000..615bb3be1143988082690293e03c5b36e6747a58 GIT binary patch literal 825 zcmex=^(PF6}rMnOeST|r4lSw=>~TvNxu(8R<c1}I=;VrF4wW9Q)H;sz?% zD!{d!pzFb!U9xX3zTPI5o8roG<0MW4oqZMDikqloVbuf*=gfJ(V&YTRE(2~ znmD<{#3dx9RMpfqG__1j&CD$#!=o3Ax_(anF0iOegJB3`=KnVVUriAz literal 0 HcmV?d00001 diff --git a/media/park/test-park/test-park_2.jpg b/media/park/test-park/test-park_2.jpg new file mode 100644 index 0000000000000000000000000000000000000000..615bb3be1143988082690293e03c5b36e6747a58 GIT binary patch literal 825 zcmex=^(PF6}rMnOeST|r4lSw=>~TvNxu(8R<c1}I=;VrF4wW9Q)H;sz?% zD!{d!pzFb!U9xX3zTPI5o8roG<0MW4oqZMDikqloVbuf*=gfJ(V&YTRE(2~ znmD<{#3dx9RMpfqG__1j&CD$#!=o3Ax_(anF0iOegJB3`=KnVVUriAz literal 0 HcmV?d00001 diff --git a/media/park/test-park/test-park_3.jpg b/media/park/test-park/test-park_3.jpg new file mode 100644 index 0000000000000000000000000000000000000000..615bb3be1143988082690293e03c5b36e6747a58 GIT binary patch literal 825 zcmex=^(PF6}rMnOeST|r4lSw=>~TvNxu(8R<c1}I=;VrF4wW9Q)H;sz?% zD!{d!pzFb!U9xX3zTPI5o8roG<0MW4oqZMDikqloVbuf*=gfJ(V&YTRE(2~ znmD<{#3dx9RMpfqG__1j&CD$#!=o3Ax_(anF0iOegJB3`=KnVVUriAz literal 0 HcmV?d00001 diff --git a/media/park/test-park/test-park_4.jpg b/media/park/test-park/test-park_4.jpg new file mode 100644 index 0000000000000000000000000000000000000000..615bb3be1143988082690293e03c5b36e6747a58 GIT binary patch literal 825 zcmex=^(PF6}rMnOeST|r4lSw=>~TvNxu(8R<c1}I=;VrF4wW9Q)H;sz?% zD!{d!pzFb!U9xX3zTPI5o8roG<0MW4oqZMDikqloVbuf*=gfJ(V&YTRE(2~ znmD<{#3dx9RMpfqG__1j&CD$#!=o3Ax_(anF0iOegJB3`=KnVVUriAz literal 0 HcmV?d00001 diff --git a/media/park/test-park/test-park_5.jpg b/media/park/test-park/test-park_5.jpg new file mode 100644 index 0000000000000000000000000000000000000000..615bb3be1143988082690293e03c5b36e6747a58 GIT binary patch literal 825 zcmex=^(PF6}rMnOeST|r4lSw=>~TvNxu(8R<c1}I=;VrF4wW9Q)H;sz?% zD!{d!pzFb!U9xX3zTPI5o8roG<0MW4oqZMDikqloVbuf*=gfJ(V&YTRE(2~ znmD<{#3dx9RMpfqG__1j&CD$#!=o3Ax_(anF0iOegJB3`=KnVVUriAz literal 0 HcmV?d00001 diff --git a/media/park/test-park/test-park_6.jpg b/media/park/test-park/test-park_6.jpg new file mode 100644 index 0000000000000000000000000000000000000000..615bb3be1143988082690293e03c5b36e6747a58 GIT binary patch literal 825 zcmex=^(PF6}rMnOeST|r4lSw=>~TvNxu(8R<c1}I=;VrF4wW9Q)H;sz?% zD!{d!pzFb!U9xX3zTPI5o8roG<0MW4oqZMDikqloVbuf*=gfJ(V&YTRE(2~ znmD<{#3dx9RMpfqG__1j&CD$#!=o3Ax_(anF0iOegJB3`=KnVVUriAz literal 0 HcmV?d00001 diff --git a/media/storage.py b/media/storage.py index 0069280f..b89f0175 100644 --- a/media/storage.py +++ b/media/storage.py @@ -1,16 +1,30 @@ from django.core.files.storage import FileSystemStorage from django.conf import settings +from django.core.files.base import File +from django.core.files.move import file_move_safe +from django.core.files.uploadedfile import UploadedFile, TemporaryUploadedFile import os +import re +from typing import Optional, Any, Union class MediaStorage(FileSystemStorage): - def __init__(self, *args, **kwargs): + _instance = None + _counters = {} + + def __init__(self, *args: Any, **kwargs: Any) -> None: kwargs['location'] = settings.MEDIA_ROOT kwargs['base_url'] = settings.MEDIA_URL super().__init__(*args, **kwargs) - def get_available_name(self, name, max_length=None): + @classmethod + def reset_counters(cls): + """Reset all counters - useful for testing""" + cls._counters = {} + + def get_available_name(self, name: str, max_length: Optional[int] = None) -> str: """ Returns a filename that's free on the target storage system. + Ensures proper normalization and uniqueness. """ # Get the directory and filename directory = os.path.dirname(name) @@ -20,19 +34,49 @@ class MediaStorage(FileSystemStorage): full_dir = os.path.join(self.location, directory) os.makedirs(full_dir, exist_ok=True) - # Return the name as is since our upload path already handles uniqueness - return name + # Split filename into root and extension + file_root, file_ext = os.path.splitext(filename) + + # Extract base name without any existing numbers + base_root = file_root.rsplit('_', 1)[0] + + # Use counter for this directory + dir_key = os.path.join(directory, base_root) + if dir_key not in self._counters: + self._counters[dir_key] = 0 + + self._counters[dir_key] += 1 + counter = self._counters[dir_key] + + new_name = f"{base_root}_{counter}{file_ext}" + return os.path.join(directory, new_name) - def _save(self, name, content): + def _save(self, name: str, content: Union[File, UploadedFile]) -> str: """ - Save with proper permissions + Save the file and set proper permissions """ - # Save the file - name = super()._save(name, content) + # Get the full path where the file will be saved + full_path = self.path(name) + directory = os.path.dirname(full_path) + + # Create the directory if it doesn't exist + os.makedirs(directory, exist_ok=True) + + # Save the file using Django's file handling + if isinstance(content, TemporaryUploadedFile): + # This is a TemporaryUploadedFile + file_move_safe(content.temporary_file_path(), full_path) + else: + # This is an InMemoryUploadedFile or similar + with open(full_path, 'wb') as destination: + if hasattr(content, 'chunks'): + for chunk in content.chunks(): + destination.write(chunk) + else: + destination.write(content.read()) # Set proper permissions - full_path = self.path(name) os.chmod(full_path, 0o644) - os.chmod(os.path.dirname(full_path), 0o755) + os.chmod(directory, 0o755) return name diff --git a/media/submissions/photos/test_OVKudHN.gif b/media/submissions/photos/test_OVKudHN.gif new file mode 100644 index 0000000000000000000000000000000000000000..0ad774e841f10aff88fd944f22a2726cacaa9a6e GIT binary patch literal 35 kcmZ?wbh9u|WMp7uXkcUjg5>069S{u?VPIl%VPvod09%0s1poj5 literal 0 HcmV?d00001 diff --git a/media/submissions/photos/test_yp9psr1.gif b/media/submissions/photos/test_yp9psr1.gif new file mode 100644 index 0000000000000000000000000000000000000000..0ad774e841f10aff88fd944f22a2726cacaa9a6e GIT binary patch literal 35 kcmZ?wbh9u|WMp7uXkcUjg5>069S{u?VPIl%VPvod09%0s1poj5 literal 0 HcmV?d00001 diff --git a/media/tests.py b/media/tests.py index 38a88019..ff9f2e56 100644 --- a/media/tests.py +++ b/media/tests.py @@ -3,74 +3,192 @@ from django.core.files.uploadedfile import SimpleUploadedFile from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType from django.utils import timezone +from django.conf import settings +from django.test.utils import override_settings +from django.db import models from datetime import datetime -from PIL import Image, ExifTags +from PIL import Image +import piexif import io +import shutil +import tempfile +import os +import logging +from typing import Optional, Any, Generator, cast +from contextlib import contextmanager from .models import Photo +from .storage import MediaStorage from parks.models import Park User = get_user_model() +logger = logging.getLogger(__name__) +@override_settings(MEDIA_ROOT=tempfile.mkdtemp()) class PhotoModelTests(TestCase): - def setUp(self): - # Create a test user - self.user = User.objects.create_user( + test_media_root: str + user: models.Model + park: Park + content_type: ContentType + + @classmethod + def setUpClass(cls) -> None: + super().setUpClass() + cls.test_media_root = settings.MEDIA_ROOT + + @classmethod + def tearDownClass(cls) -> None: + try: + shutil.rmtree(cls.test_media_root, ignore_errors=True) + except Exception as e: + logger.warning(f"Failed to clean up test media directory: {e}") + super().tearDownClass() + + def setUp(self) -> None: + self.user = self._create_test_user() + self.park = self._create_test_park() + self.content_type = ContentType.objects.get_for_model(Park) + self._setup_test_directory() + + def tearDown(self) -> None: + self._cleanup_test_directory() + Photo.objects.all().delete() + with self._reset_storage_state(): + pass + + def _create_test_user(self) -> models.Model: + """Create a test user for the tests""" + return User.objects.create_user( username='testuser', password='testpass123' ) - - # Create a test park for photo association - self.park = Park.objects.create( + + def _create_test_park(self) -> Park: + """Create a test park for the tests""" + return Park.objects.create( name='Test Park', slug='test-park' ) - - self.content_type = ContentType.objects.get_for_model(Park) - def create_test_image_with_exif(self, date_taken=None): + def _setup_test_directory(self) -> None: + """Set up test directory and clean any existing test files""" + try: + # Clean up any existing test park directory + test_park_dir = os.path.join(settings.MEDIA_ROOT, 'park', 'test-park') + if os.path.exists(test_park_dir): + shutil.rmtree(test_park_dir, ignore_errors=True) + + # Create necessary directories + os.makedirs(test_park_dir, exist_ok=True) + + except Exception as e: + logger.warning(f"Failed to set up test directory: {e}") + raise + + def _cleanup_test_directory(self) -> None: + """Clean up test directories and files""" + try: + test_park_dir = os.path.join(settings.MEDIA_ROOT, 'park', 'test-park') + if os.path.exists(test_park_dir): + shutil.rmtree(test_park_dir, ignore_errors=True) + except Exception as e: + logger.warning(f"Failed to clean up test directory: {e}") + + @contextmanager + def _reset_storage_state(self) -> Generator[None, None, None]: + """Safely reset storage state""" + try: + MediaStorage.reset_counters() + yield + finally: + MediaStorage.reset_counters() + + def create_test_image_with_exif(self, date_taken: Optional[datetime] = None, filename: str = 'test.jpg') -> SimpleUploadedFile: """Helper method to create a test image with EXIF data""" - # Create a test image image = Image.new('RGB', (100, 100), color='red') image_io = io.BytesIO() - # Add EXIF data if date_taken is provided + # Save image first without EXIF + image.save(image_io, 'JPEG') + image_io.seek(0) + if date_taken: + # Create EXIF data exif_dict = { "0th": {}, "Exif": { - ExifTags.Base.DateTimeOriginal: date_taken.strftime("%Y:%m:%d %H:%M:%S").encode() + piexif.ExifIFD.DateTimeOriginal: date_taken.strftime("%Y:%m:%d %H:%M:%S").encode() } } - image.save(image_io, 'JPEG', exif=exif_dict) + exif_bytes = piexif.dump(exif_dict) + + # Insert EXIF into image + image_with_exif = io.BytesIO() + piexif.insert(exif_bytes, image_io.getvalue(), image_with_exif) + image_with_exif.seek(0) + image_data = image_with_exif.getvalue() else: - image.save(image_io, 'JPEG') + image_data = image_io.getvalue() - image_io.seek(0) return SimpleUploadedFile( - 'test.jpg', - image_io.getvalue(), + filename, + image_data, content_type='image/jpeg' ) - def test_photo_creation(self): - """Test basic photo creation""" - photo = Photo.objects.create( - image=SimpleUploadedFile( - 'test.jpg', - b'dummy image data', - content_type='image/jpeg' - ), - caption='Test Caption', - uploaded_by=self.user, - content_type=self.content_type, - object_id=self.park.pk - ) - - self.assertEqual(photo.caption, 'Test Caption') - self.assertEqual(photo.uploaded_by, self.user) - self.assertIsNone(photo.date_taken) + def test_filename_normalization(self) -> None: + """Test that filenames are properly normalized""" + with self._reset_storage_state(): + # Test with various problematic filenames + test_cases = [ + ('test with spaces.jpg', 'test-park_1.jpg'), + ('TEST_UPPER.JPG', 'test-park_2.jpg'), + ('special@#chars.jpeg', 'test-park_3.jpg'), + ('no-extension', 'test-park_4.jpg'), + ('multiple...dots.jpg', 'test-park_5.jpg'), + ('très_açaí.jpg', 'test-park_6.jpg'), # Unicode characters + ] - def test_exif_date_extraction(self): + for input_name, expected_suffix in test_cases: + photo = Photo.objects.create( + image=self.create_test_image_with_exif(filename=input_name), + uploaded_by=self.user, + content_type=self.content_type, + object_id=self.park.pk + ) + + # Check that the filename follows the normalized pattern + self.assertTrue( + photo.image.name.endswith(expected_suffix), + f"Expected filename to end with {expected_suffix}, got {photo.image.name}" + ) + + # Verify the path structure + expected_path = f"park/{self.park.slug}/" + self.assertTrue( + photo.image.name.startswith(expected_path), + f"Expected path to start with {expected_path}, got {photo.image.name}" + ) + + def test_sequential_filename_numbering(self) -> None: + """Test that sequential files get proper numbering""" + with self._reset_storage_state(): + # Create multiple photos and verify numbering + for i in range(1, 4): + photo = Photo.objects.create( + image=self.create_test_image_with_exif(), + uploaded_by=self.user, + content_type=self.content_type, + object_id=self.park.pk + ) + + expected_name = f"park/{self.park.slug}/test-park_{i}.jpg" + self.assertEqual( + photo.image.name, + expected_name, + f"Expected {expected_name}, got {photo.image.name}" + ) + + def test_exif_date_extraction(self) -> None: """Test EXIF date extraction from uploaded photos""" test_date = datetime(2024, 1, 1, 12, 0, 0) image_file = self.create_test_image_with_exif(test_date) @@ -90,9 +208,9 @@ class PhotoModelTests(TestCase): else: self.skipTest("EXIF data extraction not supported in test environment") - def test_photo_without_exif(self): + def test_photo_without_exif(self) -> None: """Test photo upload without EXIF data""" - image_file = self.create_test_image_with_exif() # No date provided + image_file = self.create_test_image_with_exif() photo = Photo.objects.create( image=image_file, @@ -103,31 +221,22 @@ class PhotoModelTests(TestCase): self.assertIsNone(photo.date_taken) - def test_default_caption(self): + def test_default_caption(self) -> None: """Test default caption generation""" photo = Photo.objects.create( - image=SimpleUploadedFile( - 'test.jpg', - b'dummy image data', - content_type='image/jpeg' - ), + image=self.create_test_image_with_exif(), uploaded_by=self.user, content_type=self.content_type, object_id=self.park.pk ) - expected_prefix = f"Uploaded by {self.user.username} on" + expected_prefix = f"Uploaded by {cast(Any, self.user).username} on" self.assertTrue(photo.caption.startswith(expected_prefix)) - def test_primary_photo_toggle(self): + def test_primary_photo_toggle(self) -> None: """Test primary photo functionality""" - # Create two photos photo1 = Photo.objects.create( - image=SimpleUploadedFile( - 'test1.jpg', - b'dummy image data', - content_type='image/jpeg' - ), + image=self.create_test_image_with_exif(), uploaded_by=self.user, content_type=self.content_type, object_id=self.park.pk, @@ -135,51 +244,24 @@ class PhotoModelTests(TestCase): ) photo2 = Photo.objects.create( - image=SimpleUploadedFile( - 'test2.jpg', - b'dummy image data', - content_type='image/jpeg' - ), + image=self.create_test_image_with_exif(), uploaded_by=self.user, content_type=self.content_type, object_id=self.park.pk, is_primary=True ) - # Refresh from database photo1.refresh_from_db() photo2.refresh_from_db() - # Verify only photo2 is primary self.assertFalse(photo1.is_primary) self.assertTrue(photo2.is_primary) - @override_settings(MEDIA_ROOT='test_media/') - def test_photo_upload_path(self): - """Test photo upload path generation""" - photo = Photo.objects.create( - image=SimpleUploadedFile( - 'test.jpg', - b'dummy image data', - content_type='image/jpeg' - ), - uploaded_by=self.user, - content_type=self.content_type, - object_id=self.park.pk - ) - - expected_path = f"park/{self.park.slug}/" - self.assertTrue(photo.image.name.startswith(expected_path)) - - def test_date_taken_field(self): + def test_date_taken_field(self) -> None: """Test date_taken field functionality""" test_date = timezone.now() photo = Photo.objects.create( - image=SimpleUploadedFile( - 'test.jpg', - b'dummy image data', - content_type='image/jpeg' - ), + image=self.create_test_image_with_exif(), uploaded_by=self.user, content_type=self.content_type, object_id=self.park.pk, diff --git a/moderation/mixins.py b/moderation/mixins.py index 8a080670..c17c2af5 100644 --- a/moderation/mixins.py +++ b/moderation/mixins.py @@ -1,17 +1,27 @@ +from typing import Any, Dict, Optional, Type, Union, cast from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin from django.contrib.contenttypes.models import ContentType -from django.http import JsonResponse, HttpResponseForbidden +from django.http import JsonResponse, HttpResponseForbidden, HttpRequest, HttpResponse from django.core.exceptions import PermissionDenied -from django.views.generic import DetailView +from django.views.generic import DetailView, View from django.utils import timezone +from django.db import models +from django.contrib.auth import get_user_model +from django.contrib.auth.base_user import AbstractBaseUser +from django.contrib.auth.models import AnonymousUser import json -from .models import EditSubmission, PhotoSubmission +from .models import EditSubmission, PhotoSubmission, UserType -class EditSubmissionMixin: +User = get_user_model() + +class EditSubmissionMixin(DetailView): """ Mixin for handling edit submissions with proper moderation. """ - def handle_edit_submission(self, request, changes, reason='', source='', submission_type='EDIT'): + model: Optional[Type[models.Model]] = None + + def handle_edit_submission(self, request: HttpRequest, changes: Dict[str, Any], reason: str = '', + source: str = '', submission_type: str = 'EDIT') -> JsonResponse: """ Handle an edit submission based on user's role. @@ -31,6 +41,9 @@ class EditSubmissionMixin: 'message': 'You must be logged in to make edits.' }, status=403) + if not self.model: + raise ValueError("model attribute must be set") + content_type = ContentType.objects.get_for_model(self.model) # Create the submission @@ -46,16 +59,17 @@ class EditSubmissionMixin: # For edits, set the object_id if submission_type == 'EDIT': obj = self.get_object() - submission.object_id = obj.id + submission.object_id = getattr(obj, 'id', None) # Auto-approve for moderators and above - if request.user.role in ['MODERATOR', 'ADMIN', 'SUPERUSER']: - obj = submission.approve(request.user) + user_role = getattr(request.user, 'role', None) + if user_role in ['MODERATOR', 'ADMIN', 'SUPERUSER']: + obj = submission.approve(cast(UserType, request.user)) return JsonResponse({ 'status': 'success', 'message': 'Changes saved successfully.', 'auto_approved': True, - 'redirect_url': obj.get_absolute_url() if hasattr(obj, 'get_absolute_url') else None + 'redirect_url': getattr(obj, 'get_absolute_url', lambda: None)() }) # Submit for approval for regular users @@ -66,7 +80,7 @@ class EditSubmissionMixin: 'auto_approved': False }) - def post(self, request, *args, **kwargs): + def post(self, request: HttpRequest, *args: Any, **kwargs: Any) -> JsonResponse: """Handle POST requests for editing""" if not request.user.is_authenticated: return JsonResponse({ @@ -87,7 +101,8 @@ class EditSubmissionMixin: 'message': 'No changes provided.' }, status=400) - if not reason and request.user.role == 'USER': + user_role = getattr(request.user, 'role', None) + if not reason and user_role == 'USER': return JsonResponse({ 'status': 'error', 'message': 'Please provide a reason for your changes.' @@ -108,11 +123,13 @@ class EditSubmissionMixin: 'message': str(e) }, status=500) -class PhotoSubmissionMixin: +class PhotoSubmissionMixin(DetailView): """ Mixin for handling photo submissions with proper moderation. """ - def handle_photo_submission(self, request): + model: Optional[Type[models.Model]] = None + + def handle_photo_submission(self, request: HttpRequest) -> JsonResponse: """Handle a photo submission based on user's role""" if not request.user.is_authenticated: return JsonResponse({ @@ -120,6 +137,9 @@ class PhotoSubmissionMixin: 'message': 'You must be logged in to upload photos.' }, status=403) + if not self.model: + raise ValueError("model attribute must be set") + try: obj = self.get_object() except (AttributeError, self.model.DoesNotExist): @@ -139,14 +159,15 @@ class PhotoSubmissionMixin: submission = PhotoSubmission( user=request.user, content_type=content_type, - object_id=obj.id, + object_id=getattr(obj, 'id', None), photo=request.FILES['photo'], caption=request.POST.get('caption', ''), date_taken=request.POST.get('date_taken') ) # Auto-approve for moderators and above - if request.user.role in ['MODERATOR', 'ADMIN', 'SUPERUSER']: + user_role = getattr(request.user, 'role', None) + if user_role in ['MODERATOR', 'ADMIN', 'SUPERUSER']: submission.auto_approve() return JsonResponse({ 'status': 'success', @@ -164,63 +185,81 @@ class PhotoSubmissionMixin: class ModeratorRequiredMixin(UserPassesTestMixin): """Require moderator or higher role for access""" - def test_func(self): + request: Optional[HttpRequest] = None + + def test_func(self) -> bool: + if not self.request: + return False + user_role = getattr(self.request.user, 'role', None) return ( self.request.user.is_authenticated and - self.request.user.role in ['MODERATOR', 'ADMIN', 'SUPERUSER'] + user_role in ['MODERATOR', 'ADMIN', 'SUPERUSER'] ) - def handle_no_permission(self): - if not self.request.user.is_authenticated: + def handle_no_permission(self) -> HttpResponse: + if not self.request or not self.request.user.is_authenticated: return super().handle_no_permission() return HttpResponseForbidden("You must be a moderator to access this page.") class AdminRequiredMixin(UserPassesTestMixin): """Require admin or superuser role for access""" - def test_func(self): + request: Optional[HttpRequest] = None + + def test_func(self) -> bool: + if not self.request: + return False + user_role = getattr(self.request.user, 'role', None) return ( self.request.user.is_authenticated and - self.request.user.role in ['ADMIN', 'SUPERUSER'] + user_role in ['ADMIN', 'SUPERUSER'] ) - def handle_no_permission(self): - if not self.request.user.is_authenticated: + def handle_no_permission(self) -> HttpResponse: + if not self.request or not self.request.user.is_authenticated: return super().handle_no_permission() return HttpResponseForbidden("You must be an admin to access this page.") class InlineEditMixin: """Add inline editing context to views""" - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - if hasattr(self, 'request') and self.request.user.is_authenticated: + request: Optional[HttpRequest] = None + + def get_context_data(self, **kwargs: Any) -> Dict[str, Any]: + context = super().get_context_data(**kwargs) # type: ignore + if self.request and self.request.user.is_authenticated: context['can_edit'] = True - context['can_auto_approve'] = self.request.user.role in ['MODERATOR', 'ADMIN', 'SUPERUSER'] + user_role = getattr(self.request.user, 'role', None) + context['can_auto_approve'] = user_role in ['MODERATOR', 'ADMIN', 'SUPERUSER'] + if isinstance(self, DetailView): - obj = self.get_object() + obj = self.get_object() # type: ignore context['pending_edits'] = EditSubmission.objects.filter( - content_type=ContentType.objects.get_for_model(obj), - object_id=obj.id, + content_type=ContentType.objects.get_for_model(obj.__class__), + object_id=getattr(obj, 'id', None), status='NEW' ).select_related('user').order_by('-created_at') return context class HistoryMixin: """Add edit history context to views""" - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) + def get_context_data(self, **kwargs: Any) -> Dict[str, Any]: + context = super().get_context_data(**kwargs) # type: ignore # Only add history context for DetailViews if isinstance(self, DetailView): - obj = self.get_object() + obj = self.get_object() # type: ignore - # Get historical records ordered by date - context['history'] = obj.history.all().select_related('history_user').order_by('-history_date') + # Get historical records ordered by date if available + history = getattr(obj, 'history', None) + if history is not None: + context['history'] = history.all().select_related('history_user').order_by('-history_date') + else: + context['history'] = [] # Get related edit submissions - content_type = ContentType.objects.get_for_model(obj) + content_type = ContentType.objects.get_for_model(obj.__class__) context['edit_submissions'] = EditSubmission.objects.filter( content_type=content_type, - object_id=obj.id + object_id=getattr(obj, 'id', None) ).exclude( status='NEW' ).select_related('user', 'handled_by').order_by('-created_at') diff --git a/moderation/models.py b/moderation/models.py index 945839e9..856fa911 100644 --- a/moderation/models.py +++ b/moderation/models.py @@ -1,9 +1,15 @@ +from typing import Any, Dict, Optional, Type, Union, cast from django.db import models from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType from django.conf import settings from django.utils import timezone from django.apps import apps +from django.core.exceptions import ObjectDoesNotExist, FieldDoesNotExist +from django.contrib.auth.base_user import AbstractBaseUser +from django.contrib.auth.models import AnonymousUser + +UserType = Union[AbstractBaseUser, AnonymousUser] class EditSubmission(models.Model): STATUS_CHOICES = [ @@ -78,60 +84,76 @@ class EditSubmission(models.Model): models.Index(fields=['status']), ] - def __str__(self): + def __str__(self) -> str: action = "creation" if self.submission_type == 'CREATE' else "edit" - target = self.content_object or self.content_type.model_class().__name__ + model_class = self.content_type.model_class() + target = self.content_object or (model_class.__name__ if model_class else 'Unknown') return f"{action} by {self.user.username} on {target}" - def _resolve_foreign_keys(self, data): + def _resolve_foreign_keys(self, data: Dict[str, Any]) -> Dict[str, Any]: """Convert foreign key IDs to model instances""" model_class = self.content_type.model_class() + if not model_class: + raise ValueError("Could not resolve model class") + resolved_data = data.copy() for field_name, value in data.items(): - field = model_class._meta.get_field(field_name) - if isinstance(field, models.ForeignKey) and value is not None: - related_model = field.related_model - resolved_data[field_name] = related_model.objects.get(id=value) + try: + field = model_class._meta.get_field(field_name) + if isinstance(field, models.ForeignKey) and value is not None: + related_model = field.related_model + if related_model: + resolved_data[field_name] = related_model.objects.get(id=value) + except (FieldDoesNotExist, ObjectDoesNotExist): + continue return resolved_data - def approve(self, user): + def approve(self, user: UserType) -> Optional[models.Model]: """Approve the submission and apply the changes""" self.status = 'APPROVED' - self.handled_by = user + self.handled_by = user # type: ignore self.handled_at = timezone.now() model_class = self.content_type.model_class() - resolved_data = self._resolve_foreign_keys(self.changes) + if not model_class: + raise ValueError("Could not resolve model class") - if self.submission_type == 'CREATE': - # Create new object - obj = model_class(**resolved_data) - obj.save() - # Update object_id after creation - self.object_id = obj.id - else: - # Apply changes to existing object - obj = self.content_object - for field, value in resolved_data.items(): - setattr(obj, field, value) - obj.save() - - self.save() - return obj + try: + resolved_data = self._resolve_foreign_keys(self.changes) - def reject(self, user): + if self.submission_type == 'CREATE': + # Create new object + obj = model_class(**resolved_data) + obj.save() + # Update object_id after creation + self.object_id = getattr(obj, 'id', None) + else: + # Apply changes to existing object + obj = self.content_object + if not obj: + raise ValueError("Content object not found") + for field, value in resolved_data.items(): + setattr(obj, field, value) + obj.save() + + self.save() + return obj + except Exception as e: + raise ValueError(f"Error approving submission: {str(e)}") from e + + def reject(self, user: UserType) -> None: """Reject the submission""" self.status = 'REJECTED' - self.handled_by = user + self.handled_by = user # type: ignore self.handled_at = timezone.now() self.save() - def escalate(self, user): + def escalate(self, user: UserType) -> None: """Escalate the submission to admin""" self.status = 'ESCALATED' - self.handled_by = user + self.handled_by = user # type: ignore self.handled_at = timezone.now() self.save() @@ -189,15 +211,15 @@ class PhotoSubmission(models.Model): models.Index(fields=['status']), ] - def __str__(self): + def __str__(self) -> str: return f"Photo submission by {self.user.username} for {self.content_object}" - def approve(self, moderator, notes=''): + def approve(self, moderator: UserType, notes: str = '') -> None: """Approve the photo submission""" from media.models import Photo self.status = 'APPROVED' - self.handled_by = moderator + self.handled_by = moderator # type: ignore self.handled_at = timezone.now() self.notes = notes @@ -213,15 +235,15 @@ class PhotoSubmission(models.Model): self.save() - def reject(self, moderator, notes): + def reject(self, moderator: UserType, notes: str) -> None: """Reject the photo submission""" self.status = 'REJECTED' - self.handled_by = moderator + self.handled_by = moderator # type: ignore self.handled_at = timezone.now() self.notes = notes self.save() - def auto_approve(self): + def auto_approve(self) -> None: """Auto-approve the photo submission (for moderators/admins)""" from media.models import Photo diff --git a/moderation/tests.py b/moderation/tests.py index 3240bca2..46ec4662 100644 --- a/moderation/tests.py +++ b/moderation/tests.py @@ -14,6 +14,7 @@ from companies.models import Company from django.views.generic import DetailView from django.test import RequestFactory import json +from typing import Optional User = get_user_model() @@ -28,7 +29,7 @@ class TestView(EditSubmissionMixin, PhotoSubmissionMixin, InlineEditMixin, Histo self.object = self.get_object() return super().get_context_data(**kwargs) - def setup(self, request, *args, **kwargs): + def setup(self, request: HttpRequest, *args, **kwargs): super().setup(request, *args, **kwargs) self.request = request @@ -224,8 +225,7 @@ class ModerationMixinsTests(TestCase): def test_moderator_required_mixin(self): """Test moderator required mixin""" class TestModeratorView(ModeratorRequiredMixin): - def __init__(self): - self.request = None + pass view = TestModeratorView() @@ -253,8 +253,7 @@ class ModerationMixinsTests(TestCase): def test_admin_required_mixin(self): """Test admin required mixin""" class TestAdminView(AdminRequiredMixin): - def __init__(self): - self.request = None + pass view = TestAdminView() @@ -319,7 +318,7 @@ class ModerationMixinsTests(TestCase): EditSubmission.objects.create( user=self.user, content_type=ContentType.objects.get_for_model(Company), - object_id=self.company.id, + object_id=getattr(self.company, 'id', None), submission_type='EDIT', changes={'name': 'New Name'}, status='APPROVED' diff --git a/parks/__pycache__/models.cpython-312.pyc b/parks/__pycache__/models.cpython-312.pyc index b42662cb81efea638f06819545948d758b7a426e..cee51a3d42e841ca6c539ed038dc08bdf5e1cd85 100644 GIT binary patch delta 4114 zcmcImX-r$$6~52*d)Rn4HpVuKSsE}9l8|Jwhb?SrLN>CDo5Aw%9>j^U$+aB_^D&cRKlgEY6Hm;i{CP0^;Xqr zjTHHd6kQj#k2w4eMK25&k2w8KMc0Q*MqGZEq8q~Q5s%-)5f$ksqVWb1O_FO~jl&S) zFIB8&uv*qw%f_uf+m{INak4P=Fzi&ze1BpzEXmp=iE5Ew5av}GtG`ge z6|cu&4NwL1Toz@>czy{=EAgUkp)kKxBOpzZ*)HlA4EaGUO7|-EH@nkn%v)`%*5sWZwUxT88_!2`==10M8%%_oc(MUj)!cttK83?;9RB;XL$HMRGb^)>l z{+JHL4YFc)-`;skOIx8v+d;?zmEs8+VYdo)pTN1o@%#o zt!$(2T+Lx8<0uOuFZD(KUwJO@k z(gr8{TwkH07IwSvtWBBM8T5du#tZwx{#m%$gpOml#n;FedNaFgsBnI6cCI)pmYv%d zo!gh3J5rV%D`qRrj54`?l)BYX4D9dvQ@uCu$}BZgU^+lNyDPEA!TWRGs+zMVfK52i~E?(xCXd+_Al2MH^4qIwyVxt zSlZa8ab8tlVHXP>4pGR3+!gh8l~{09{ay7KGk7YBS6i-gW8|{>buz~Jv~1GUuoriv z>zQ%mxlk+~rQ-n+CJ$FiHF}|cqO>pS#XIXAiY6kWcPtb?=Zyo8d*IUj%AK91O}Ota`ypGqS0Js9l&*e2I3k?3x&E4jt!XWR8ZVB zHz$vCE1vSZdp_86x8;MD+2Q4;{fkZemz&xao7$#ZmOSmB97uV3KC|~cU2(dXowbY3 z+DC-5LN9mOvb%oKU7u>`U2-2x9X*z^ANwY)#s=95-)}Au7r>$BT z_}ODj&DYDkWTvgK!>#^xL+_UM3f28m4l4I6%pE*`zsc4?_)iHB`lq}D?7r9N9_U4p z|L8stvN{xr%jQ5J7>PvVLF7+}0(QnyX;tDzcLMgTD%gg;XnXF@EBN?h-=LRt(_1jp&*F` zTQ)%`J^eme(dxg~{fq6kOI-3F8jdf0NI;|oj(1HLJqF#C7S#NP>FP9rVzx*r?%BV_ zTDp+qd$^fug!OP~fe5Cwisx-J9gheUkJULt+N5X#Z=XIhBh7Z++dg|}al@{~vfYme z7_-16sA&rktZ9{j@8G6mX@ccUV)oR%{@JsOwSYzg!mUpfOeUgmNrUj`zk;mx_z#dp zU-ixbvg6O=7U#Y#Sw$*<2Hc|OK2f!x&Oc3b*q!~v?kLhM@VP+s>lLRZ?3&u1Tw}&} z41d1#Ie$g2x<&iqZw*+D~bvfUJ(7X8dZFv(QPPRW47X6 zB^YTP)-m(-`FJ)}Y;TawiAd;TLJGvA!w@GBeaOL?%YuxmP%oVZ?JYK6T&i!)+d9~9 zixXQg#4}#kvKAUKYBO0Mr=6qXB#SwH=2isc$y1@-JxIIs5Q?K9a#?dNiyCu#(1c8S z9ihijw6dX+pVjt*3QrP+M)S*D$EAop$#%HPxtr{iYm?B5hW{JKw0L}dK^UQnzKP>t z)b&qP=7shiU|~Np}2zLZ4~dI_#p};2Z~#ugM$~qVXoE6 zj+Q%`ZGce@N!g9(l7Ot;3*Hl9X+Qlr;9!>syp`WCFWdDeQoBfMACRqoAvF(3IVcZE f%@g4}9M{23wmcz<^5lxDk?Y|mlV1}om4*Hr9>k=p delta 3385 zcmb_eU2Ggz6~1?NXT1Nrvp?Ql@2%aM5MB@!;NXXt6_Fw#9(Z#cS40t1oO5@* z{;9(gEA2PuoO}M}-gD2HkGuZ@wxQCM}Zrj2>sQKROU5GbfVkxzF}2U z1#Qp!hXYzb(2jg?IHZLH?aWsXhqW*z5_y5>u4$sXjnKRWT@s>2gwz94?}MO_+Uh)#b-HH$!3)jl7WY{~Q<837 zkmsec+$OC3>oyxt&{8Vaj;F?`GAz1n!CrX}Cuq71x9(VQR$_RBb#2M_)J6zGbSs2$ zRTO=j75|5x`qXsr<=hp2exbK(vnNuDuX}W_2=1G=mX@`kP~N>oSuk66qIw6HB`x)H z9>YR+?^cf@{5*xorDn+MRF6*Ev}g4i z;M2x#JvL1$rp>^|H}NgT9zC&%Z=E5Rsn*s@(zQivPnHew3-Z!435Z-&#z%6O#*H~z zlwZqdM~40x1S!KRPg_`m_bEr*Nn|93>MY6MRa%r(iTtCISSMGM2Abl5sy`(U!rF28 zn|6R{vK*}W@!{(>b_`8W&h54%r}1DQq}4R?ubX)BlI(fFVpS$%j5>nY=2}V5fn*Vizf(br-OYngJe{6L@p-Z;`rL>(A}C z>`M_oYY)?7{I>mKV>jr`QT8Qxz=okuh9V|jp!}M=%h5v5@Jo&cFruy~{Q*zfVqA*{ zd5^~?vE4lB3iHn#b-doG`7vwO3t(a!O2Rhrx14*GvnV;lf9FgEmjkhTfqi!a`<4O+ z7F`FHy(%mDo!;tqSH~FwTy!2(bjva zFLUmdvuBHPVPeE!%*}u5KQQG)HQW?&<=9E2u&7xNK+!hJ3UD>XCfQd}27LxA0B^#u zF?O+_vm8p?<=fG3P&vA$Sma%+4l19fD+*CO4<)PYe`En_1*sL6kjZkeECXuALlpI) zL?h#|Ard+@&^!p^T>zRY8kY_>b9RDgB-VR+fSzpQe`w(p*5fN1cSO| z!CL9pcHuP0eNtVOQ_vCqz!LOt8vkw~j)OR?R#nDLnmy{8>>_dCM(b z99Meye`;#!G>^yHpq2k0I^ga^I{kI<+|6D^{}%xI*f)?I0NChnD9Q0#iRjduNDd+l z0i+Y#j{#dE`wjwLlw#G%j^2|UU8hPfHH>nx(<4ZUNmW2<6oDa_2xAD>5Wb5rjZkVZ z*b#Q>FzkD{fL(;WfiMFg4zb)hMA`_LwYdxcor90o&310|3`uT&NB~xCRNi0i8CKbW z?P2Fufd0;pVR_z dpERy1=P7NPX str: return self.name - def save(self, *args, **kwargs): + def save(self, *args: Any, **kwargs: Any) -> None: if not self.slug: self.slug = slugify(self.name) super().save(*args, **kwargs) - def get_absolute_url(self): + def get_absolute_url(self) -> str: return reverse("parks:park_detail", kwargs={"slug": self.slug}) @property - def formatted_location(self): + def formatted_location(self) -> str: if self.location.exists(): location = self.location.first() - return location.get_formatted_address() + if location: + return location.get_formatted_address() return "" @property - def coordinates(self): + def coordinates(self) -> Optional[Tuple[float, float]]: """Returns coordinates as a tuple (latitude, longitude)""" if self.location.exists(): location = self.location.first() - return location.coordinates + if location: + return location.coordinates return None @classmethod - def get_by_slug(cls, slug): + def get_by_slug(cls, slug: str) -> Tuple['Park', bool]: """Get park by current or historical slug""" try: return cls.objects.get(slug=slug), False except cls.DoesNotExist: # Check historical slugs - history = cls.history.filter(slug=slug).order_by("-history_date").first() + history = cls.history.filter(slug=slug).order_by("-history_date").first() # type: ignore[attr-defined] if history: try: - return cls.objects.get(id=history.id), True - except cls.DoesNotExist: - pass - raise cls.DoesNotExist() + return cls.objects.get(pk=history.instance.pk), True + except cls.DoesNotExist as e: + raise cls.DoesNotExist("No park found with this slug") from e + raise cls.DoesNotExist("No park found with this slug") class ParkArea(HistoricalModel): + id: int # Type hint for Django's automatic id field park = models.ForeignKey(Park, on_delete=models.CASCADE, related_name="areas") name = models.CharField(max_length=255) slug = models.SlugField(max_length=255) @@ -115,37 +119,36 @@ class ParkArea(HistoricalModel): # Metadata created_at = models.DateTimeField(auto_now_add=True, null=True) updated_at = models.DateTimeField(auto_now=True) - history = HistoricalRecords() class Meta: ordering = ["name"] unique_together = ["park", "slug"] - def __str__(self): + def __str__(self) -> str: return f"{self.name} at {self.park.name}" - def save(self, *args, **kwargs): + def save(self, *args: Any, **kwargs: Any) -> None: if not self.slug: self.slug = slugify(self.name) super().save(*args, **kwargs) - def get_absolute_url(self): + def get_absolute_url(self) -> str: return reverse( "parks:area_detail", kwargs={"park_slug": self.park.slug, "area_slug": self.slug}, ) @classmethod - def get_by_slug(cls, slug): + def get_by_slug(cls, slug: str) -> Tuple['ParkArea', bool]: """Get area by current or historical slug""" try: return cls.objects.get(slug=slug), False except cls.DoesNotExist: # Check historical slugs - history = cls.history.filter(slug=slug).order_by("-history_date").first() + history = cls.history.filter(slug=slug).order_by("-history_date").first() # type: ignore[attr-defined] if history: try: - return cls.objects.get(id=history.id), True - except cls.DoesNotExist: - pass - raise cls.DoesNotExist() + return cls.objects.get(pk=history.instance.pk), True + except cls.DoesNotExist as e: + raise cls.DoesNotExist("No park area found with this slug") from e + raise cls.DoesNotExist("No park area found with this slug") diff --git a/parks/tests.py b/parks/tests.py index a9c80115..95a7831e 100644 --- a/parks/tests.py +++ b/parks/tests.py @@ -4,15 +4,32 @@ from django.contrib.auth import get_user_model from django.core.exceptions import ValidationError from django.contrib.contenttypes.models import ContentType from django.contrib.gis.geos import Point +from django.http import HttpResponse +from typing import cast, Optional, Tuple from .models import Park, ParkArea from companies.models import Company from location.models import Location User = get_user_model() +def create_test_location(park: Park) -> Location: + """Helper function to create a test location""" + return Location.objects.create( + content_type=ContentType.objects.get_for_model(Park), + object_id=park.id, + name='Test Park Location', + location_type='park', + street_address='123 Test St', + city='Test City', + state='TS', + country='Test Country', + postal_code='12345', + point=Point(-118.2437, 34.0522) + ) + class ParkModelTests(TestCase): @classmethod - def setUpTestData(cls): + def setUpTestData(cls) -> None: # Create test user cls.user = User.objects.create_user( username='testuser', @@ -35,20 +52,9 @@ class ParkModelTests(TestCase): ) # Create test location - cls.location = Location.objects.create( - content_type=ContentType.objects.get_for_model(Park), - object_id=cls.park.id, - name='Test Park Location', - location_type='park', - street_address='123 Test St', - city='Test City', - state='TS', - country='Test Country', - postal_code='12345', - point=Point(-118.2437, 34.0522) # Los Angeles coordinates - ) + cls.location = create_test_location(cls.park) - def test_park_creation(self): + def test_park_creation(self) -> None: """Test park instance creation and field values""" self.assertEqual(self.park.name, 'Test Park') self.assertEqual(self.park.owner, self.company) @@ -56,34 +62,35 @@ class ParkModelTests(TestCase): self.assertEqual(self.park.website, 'http://testpark.com') self.assertTrue(self.park.slug) - def test_park_str_representation(self): + def test_park_str_representation(self) -> None: """Test string representation of park""" self.assertEqual(str(self.park), 'Test Park') - def test_park_location(self): + def test_park_location(self) -> None: """Test park location relationship""" self.assertTrue(self.park.location.exists()) - location = self.park.location.first() - self.assertEqual(location.street_address, '123 Test St') - self.assertEqual(location.city, 'Test City') - self.assertEqual(location.state, 'TS') - self.assertEqual(location.country, 'Test Country') - self.assertEqual(location.postal_code, '12345') + if location := self.park.location.first(): + self.assertEqual(location.street_address, '123 Test St') + self.assertEqual(location.city, 'Test City') + self.assertEqual(location.state, 'TS') + self.assertEqual(location.country, 'Test Country') + self.assertEqual(location.postal_code, '12345') - def test_park_coordinates(self): + def test_park_coordinates(self) -> None: """Test park coordinates property""" coords = self.park.coordinates self.assertIsNotNone(coords) - self.assertAlmostEqual(coords[0], 34.0522, places=4) # latitude - self.assertAlmostEqual(coords[1], -118.2437, places=4) # longitude + if coords: + self.assertAlmostEqual(coords[0], 34.0522, places=4) # latitude + self.assertAlmostEqual(coords[1], -118.2437, places=4) # longitude - def test_park_formatted_location(self): + def test_park_formatted_location(self) -> None: """Test park formatted_location property""" expected = '123 Test St, Test City, TS, 12345, Test Country' self.assertEqual(self.park.formatted_location, expected) class ParkAreaTests(TestCase): - def setUp(self): + def setUp(self) -> None: # Create test company self.company = Company.objects.create( name='Test Company', @@ -98,18 +105,7 @@ class ParkAreaTests(TestCase): ) # Create test location - self.location = Location.objects.create( - content_type=ContentType.objects.get_for_model(Park), - object_id=self.park.id, - name='Test Park Location', - location_type='park', - street_address='123 Test St', # Added street_address - city='Test City', - state='TS', - country='Test Country', - postal_code='12345', - point=Point(-118.2437, 34.0522) - ) + self.location = create_test_location(self.park) # Create test area self.area = ParkArea.objects.create( @@ -118,25 +114,25 @@ class ParkAreaTests(TestCase): description='Test Description' ) - def test_area_creation(self): + def test_area_creation(self) -> None: """Test park area creation""" self.assertEqual(self.area.name, 'Test Area') self.assertEqual(self.area.park, self.park) self.assertTrue(self.area.slug) - def test_area_str_representation(self): + def test_area_str_representation(self) -> None: """Test string representation of park area""" expected = f'Test Area at {self.park.name}' self.assertEqual(str(self.area), expected) - def test_area_get_by_slug(self): + def test_area_get_by_slug(self) -> None: """Test get_by_slug class method""" area, is_historical = ParkArea.get_by_slug(self.area.slug) self.assertEqual(area, self.area) self.assertFalse(is_historical) class ParkViewTests(TestCase): - def setUp(self): + def setUp(self) -> None: self.client = Client() self.user = User.objects.create_user( username='testuser', @@ -152,43 +148,35 @@ class ParkViewTests(TestCase): owner=self.company, status='OPERATING' ) - self.location = Location.objects.create( - content_type=ContentType.objects.get_for_model(Park), - object_id=self.park.id, - name='Test Park Location', - location_type='park', - street_address='123 Test St', # Added street_address - city='Test City', - state='TS', - country='Test Country', - postal_code='12345', - point=Point(-118.2437, 34.0522) - ) + self.location = create_test_location(self.park) - def test_park_list_view(self): + def test_park_list_view(self) -> None: """Test park list view""" - response = self.client.get(reverse('parks:park_list')) + response = cast(HttpResponse, self.client.get(reverse('parks:park_list'))) self.assertEqual(response.status_code, 200) - self.assertContains(response, self.park.name) + content = response.content.decode('utf-8') + self.assertIn(self.park.name, content) - def test_park_detail_view(self): + def test_park_detail_view(self) -> None: """Test park detail view""" - response = self.client.get( + response = cast(HttpResponse, self.client.get( reverse('parks:park_detail', kwargs={'slug': self.park.slug}) - ) + )) self.assertEqual(response.status_code, 200) - self.assertContains(response, self.park.name) - self.assertContains(response, '123 Test St') + content = response.content.decode('utf-8') + self.assertIn(self.park.name, content) + self.assertIn('123 Test St', content) - def test_park_area_detail_view(self): + def test_park_area_detail_view(self) -> None: """Test park area detail view""" area = ParkArea.objects.create( park=self.park, name='Test Area' ) - response = self.client.get( + response = cast(HttpResponse, self.client.get( reverse('parks:area_detail', kwargs={'park_slug': self.park.slug, 'area_slug': area.slug}) - ) + )) self.assertEqual(response.status_code, 200) - self.assertContains(response, area.name) + content = response.content.decode('utf-8') + self.assertIn(area.name, content) diff --git a/rides/__pycache__/models.cpython-312.pyc b/rides/__pycache__/models.cpython-312.pyc index 540a227be50fea5a6ed41252fc61020fd001e7f5..9867f8ba3fd9fda4bbe4357f48cd314f8582dc4c 100644 GIT binary patch delta 922 zcmZ8eT}TvB6ux(MS9je%P1c|GXLdJp9ZhY;bgj^}Or;GGO@o12f9|X;ZD@B?a!XKJ zaO)|#LA`|`M6J-nyas`{H=Uv%v_on;8V zy$6jm%)9QY!7J;LMZ2?L;;#tF?)A58<%4E~*3eU=AQ=-R3)&dgO=JKOF^5R7ARNRt zkdPv!1QO1f|#4WKt@IOOVC^ z_0nWgou|oU)-jSMO3T4bX2WF??qS)o7vnJqqgD=_NVlj9a|#8b$wfsn(@Wejz+sSk zZOPQsw2iAdt!QhHfhbG{bwHY;d=5zYk3y_r_$XXz4) zx$8VbkR(}j({x6qybT>IbcEe;SK~N)=nms4ZOa|N&NkWwPI{KZIksEgQDJmy8_g`t z^rw{!p)nSyh~iFdxZ*1I^l~N>OilZFhJ{0deLB`wcLhviIq_oC4({T>$5+=t!aq5! zv9exZH#`%to_C&r>m$4#68)!VQwW3TUJ%{)PjnygD}E3?0HOz$L@CI2YJTIqwq2XR zIL}<(5&VcvdXMAB?7nxiWR=&AuqNLv`{Btpe39C2?lQ!UbBoVH-vI2g@4hN~QFZ=y zfeHR}l_7l}+z&}k``M~r#9!EkKUB#Rh9vRG0zdmFu*Nz9dr^b^9;Y)l^89@?6c~`&#jIg5JcsdR XobTC3hOtjfQy4ct6MrH8H|GBfVUpP8 delta 1134 zcmZ8fOKej|6rFi~FW7Nx=R+Xz-yzs}fsjw#G!7|o2qi59kit(gf{h<^T@^bq@HxU4((P=h zA3$>$;0MP@hVd;(J0`a};}uDaB#Kkcb{kE|EAGRZh=WUNp62>}VCb4@8_ya7`pymMW za3BwRn4Zzmv5AS?1lX zpD@+eXNv#UMiuviNZ0*H{PfV0D|laxOQrsJd-6Sb9E|c>Q?i~vhU`^JI#<7-^m2cB z#sTiK^_Jrg+NJ>>98J=2OD0}+2U~HZc1n6ymy42l~250Iwvc9 z3(nu5t%xDS<#pfJ&i}t%)F12Z$50Z%`cZrmaUI|RY{LZr5?bF?s9Fvt?%91M@gQkX z&iFIyECqdr+>&xIG$bnmA^K281ChEUIQHsIt7eu89H%$r&A@-j@^HkDm}qtX$tr>H z!QFmE9|CfXC1`l2cssaAJ6F%(m8xjH&j`R%JJs6e>sAPS-rXFt_v$tBV)$x@sqM)z zp0z4|(8{#;eUoETS-wr&g$(FKj8A1I5{iCD=`LF6Um=#UlD3Ug+BU!LFaJgV3xc-Q F=Pd$={qO() diff --git a/rides/models.py b/rides/models.py index 7925fe55..4fc21957 100644 --- a/rides/models.py +++ b/rides/models.py @@ -82,7 +82,6 @@ class Ride(HistoricalModel): updated_at = models.DateTimeField(auto_now=True) photos = GenericRelation('media.Photo') reviews = GenericRelation('reviews.Review') - history: HistoricalRecords = HistoricalRecords() # type: ignore class Meta: ordering = ['name'] @@ -120,7 +119,7 @@ class Ride(HistoricalModel): 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(models.Model): +class RollerCoasterStats(HistoricalModel): LAUNCH_CHOICES = [ ('CHAIN', 'Chain Lift'), ('CABLE', 'Cable Launch'), @@ -212,7 +211,6 @@ class RollerCoasterStats(models.Model): trains_count = models.PositiveIntegerField(null=True, blank=True) cars_per_train = models.PositiveIntegerField(null=True, blank=True) seats_per_car = models.PositiveIntegerField(null=True, blank=True) - history: HistoricalRecords = HistoricalRecords() # type: ignore class Meta: verbose_name = 'Roller Coaster Statistics' diff --git a/static/css/tailwind.css b/static/css/tailwind.css index 38403b80..c5876ec6 100644 --- a/static/css/tailwind.css +++ b/static/css/tailwind.css @@ -2250,6 +2250,11 @@ select { margin-right: 0.25rem; } +.mx-2 { + margin-left: 0.5rem; + margin-right: 0.5rem; +} + .mx-4 { margin-left: 1rem; margin-right: 1rem; @@ -2635,6 +2640,12 @@ select { gap: 1.5rem; } +.space-x-1 > :not([hidden]) ~ :not([hidden]) { + --tw-space-x-reverse: 0; + margin-right: calc(0.25rem * var(--tw-space-x-reverse)); + margin-left: calc(0.25rem * calc(1 - var(--tw-space-x-reverse))); +} + .space-x-2 > :not([hidden]) ~ :not([hidden]) { --tw-space-x-reverse: 0; margin-right: calc(0.5rem * var(--tw-space-x-reverse)); @@ -2701,16 +2712,31 @@ select { border-radius: 0.375rem; } +.rounded-b-lg { + border-bottom-right-radius: 0.5rem; + border-bottom-left-radius: 0.5rem; +} + .rounded-l-lg { border-top-left-radius: 0.5rem; border-bottom-left-radius: 0.5rem; } +.rounded-l-md { + border-top-left-radius: 0.375rem; + border-bottom-left-radius: 0.375rem; +} + .rounded-r-lg { border-top-right-radius: 0.5rem; border-bottom-right-radius: 0.5rem; } +.rounded-r-md { + border-top-right-radius: 0.375rem; + border-bottom-right-radius: 0.375rem; +} + .rounded-t-lg { border-top-left-radius: 0.5rem; border-top-right-radius: 0.5rem; @@ -3618,6 +3644,11 @@ select { background-color: rgb(34 197 94 / var(--tw-bg-opacity)); } +.dark\:bg-green-700:is(.dark *) { + --tw-bg-opacity: 1; + background-color: rgb(21 128 61 / var(--tw-bg-opacity)); +} + .dark\:bg-green-900:is(.dark *) { --tw-bg-opacity: 1; background-color: rgb(20 83 45 / var(--tw-bg-opacity)); @@ -3722,6 +3753,11 @@ select { color: rgb(74 222 128 / var(--tw-text-opacity)); } +.dark\:text-green-50:is(.dark *) { + --tw-text-opacity: 1; + color: rgb(240 253 244 / var(--tw-text-opacity)); +} + .dark\:text-green-900:is(.dark *) { --tw-text-opacity: 1; color: rgb(20 83 45 / var(--tw-text-opacity)); @@ -3884,6 +3920,22 @@ select { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .sm\:grid-cols-3 { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .sm\:flex-row { + flex-direction: row; + } + + .sm\:items-end { + align-items: flex-end; + } + + .sm\:items-center { + align-items: center; + } + .sm\:space-x-4 > :not([hidden]) ~ :not([hidden]) { --tw-space-x-reverse: 0; margin-right: calc(1rem * var(--tw-space-x-reverse)); @@ -3966,6 +4018,12 @@ select { align-items: center; } + .md\: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))); + } + .md\:text-2xl { font-size: 1.5rem; line-height: 2rem;