series of tests added with built-in django test support

This commit is contained in:
pacnpal
2024-11-05 18:40:39 +00:00
parent ba226c861a
commit 2e8a725933
60 changed files with 2108 additions and 274 deletions

BIN
.coverage Normal file

Binary file not shown.

3
.gitignore vendored
View File

@@ -29,3 +29,6 @@ rides/__pycache__
ssh_tools.jsonc ssh_tools.jsonc
thrillwiki/__pycache__/settings.cpython-312.pyc thrillwiki/__pycache__/settings.cpython-312.pyc
parks/__pycache__/views.cpython-312.pyc parks/__pycache__/views.cpython-312.pyc
.venv/lib/python3.12/site-packages
thrillwiki/__pycache__/urls.cpython-312.pyc
thrillwiki/__pycache__/views.cpython-312.pyc

8
.venv/bin/coverage Executable file
View File

@@ -0,0 +1,8 @@
#!/home/pac/thrillwiki/thrillwiki_django_no_react/.venv/bin/python3
# -*- coding: utf-8 -*-
import re
import sys
from coverage.cmdline import main
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(main())

8
.venv/bin/coverage-3.12 Executable file
View File

@@ -0,0 +1,8 @@
#!/home/pac/thrillwiki/thrillwiki_django_no_react/.venv/bin/python3
# -*- coding: utf-8 -*-
import re
import sys
from coverage.cmdline import main
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(main())

8
.venv/bin/coverage3 Executable file
View File

@@ -0,0 +1,8 @@
#!/home/pac/thrillwiki/thrillwiki_django_no_react/.venv/bin/python3
# -*- coding: utf-8 -*-
import re
import sys
from coverage.cmdline import main
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(main())

View File

@@ -1,3 +1,427 @@
from django.test import TestCase from django.test import TestCase, Client
from django.urls import reverse
from django.core.exceptions import ValidationError
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 .models import Company, Manufacturer
from location.models import Location
from moderation.models import EditSubmission, PhotoSubmission
from media.models import Photo
# Create your tests here. User = get_user_model()
class CompanyModelTests(TestCase):
def setUp(self):
self.company = Company.objects.create(
name='Test Company',
website='http://example.com',
headquarters='Test HQ',
description='Test Description',
total_parks=5,
total_rides=100
)
self.location = Location.objects.create(
content_type=ContentType.objects.get_for_model(Company),
object_id=self.company.pk,
name='Test Company HQ',
location_type='business',
street_address='123 Company St',
city='Company City',
state='CS',
country='Test Country',
postal_code='12345',
point=Point(-118.2437, 34.0522)
)
def test_company_creation(self):
"""Test company instance creation and field values"""
self.assertEqual(self.company.name, 'Test Company')
self.assertEqual(self.company.website, 'http://example.com')
self.assertEqual(self.company.headquarters, 'Test HQ')
self.assertEqual(self.company.description, 'Test Description')
self.assertEqual(self.company.total_parks, 5)
self.assertEqual(self.company.total_rides, 100)
self.assertTrue(self.company.slug)
def test_company_str_representation(self):
"""Test string representation of company"""
self.assertEqual(str(self.company), 'Test Company')
def test_company_get_by_slug(self):
"""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):
"""Test get_by_slug with invalid slug"""
with self.assertRaises(Company.DoesNotExist):
Company.get_by_slug('invalid-slug')
def test_company_stats(self):
"""Test company statistics fields"""
self.company.total_parks = 10
self.company.total_rides = 200
self.company.save()
company = Company.objects.get(pk=self.company.pk)
self.assertEqual(company.total_parks, 10)
self.assertEqual(company.total_rides, 200)
class ManufacturerModelTests(TestCase):
def setUp(self):
self.manufacturer = Manufacturer.objects.create(
name='Test Manufacturer',
website='http://example.com',
headquarters='Test HQ',
description='Test Description',
total_rides=50,
total_roller_coasters=20
)
self.location = Location.objects.create(
content_type=ContentType.objects.get_for_model(Manufacturer),
object_id=self.manufacturer.pk,
name='Test Manufacturer HQ',
location_type='business',
street_address='123 Manufacturer St',
city='Manufacturer City',
state='MS',
country='Test Country',
postal_code='12345',
point=Point(-118.2437, 34.0522)
)
def test_manufacturer_creation(self):
"""Test manufacturer instance creation and field values"""
self.assertEqual(self.manufacturer.name, 'Test Manufacturer')
self.assertEqual(self.manufacturer.website, 'http://example.com')
self.assertEqual(self.manufacturer.headquarters, 'Test HQ')
self.assertEqual(self.manufacturer.description, 'Test Description')
self.assertEqual(self.manufacturer.total_rides, 50)
self.assertEqual(self.manufacturer.total_roller_coasters, 20)
self.assertTrue(self.manufacturer.slug)
def test_manufacturer_str_representation(self):
"""Test string representation of manufacturer"""
self.assertEqual(str(self.manufacturer), 'Test Manufacturer')
def test_manufacturer_get_by_slug(self):
"""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):
"""Test get_by_slug with invalid slug"""
with self.assertRaises(Manufacturer.DoesNotExist):
Manufacturer.get_by_slug('invalid-slug')
def test_manufacturer_stats(self):
"""Test manufacturer statistics fields"""
self.manufacturer.total_rides = 100
self.manufacturer.total_roller_coasters = 40
self.manufacturer.save()
manufacturer = Manufacturer.objects.get(pk=self.manufacturer.pk)
self.assertEqual(manufacturer.total_rides, 100)
self.assertEqual(manufacturer.total_roller_coasters, 40)
class CompanyViewTests(TestCase):
def setUp(self):
self.client = Client()
self.user = User.objects.create_user(
username='testuser',
email='test@example.com',
password='testpass123'
)
self.moderator = User.objects.create_user(
username='moderator',
email='moderator@example.com',
password='modpass123',
role='MODERATOR'
)
self.company = Company.objects.create(
name='Test Company',
website='http://example.com',
headquarters='Test HQ',
description='Test Description'
)
self.location = Location.objects.create(
content_type=ContentType.objects.get_for_model(Company),
object_id=self.company.pk,
name='Test Company HQ',
location_type='business',
street_address='123 Company St',
city='Company City',
state='CS',
country='Test Country',
postal_code='12345',
point=Point(-118.2437, 34.0522)
)
def test_company_list_view(self):
"""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):
"""Test company list view with search"""
response = self.client.get(reverse('companies:company_list') + '?search=Test')
self.assertEqual(response.status_code, 200)
self.assertContains(response, self.company.name)
response = self.client.get(reverse('companies:company_list') + '?search=NonExistent')
self.assertEqual(response.status_code, 200)
self.assertNotContains(response, self.company.name)
def test_company_list_view_with_country_filter(self):
"""Test company list view with country filter"""
response = self.client.get(reverse('companies:company_list') + '?country=Test Country')
self.assertEqual(response.status_code, 200)
self.assertContains(response, self.company.name)
response = self.client.get(reverse('companies:company_list') + '?country=NonExistent')
self.assertEqual(response.status_code, 200)
self.assertNotContains(response, self.company.name)
def test_company_detail_view(self):
"""Test company detail view"""
response = self.client.get(
reverse('companies:company_detail', kwargs={'slug': self.company.slug})
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, self.company.name)
self.assertContains(response, self.company.website)
self.assertContains(response, self.company.headquarters)
def test_company_detail_view_invalid_slug(self):
"""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):
"""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):
"""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):
"""Test creating a company submission as regular user"""
self.client.login(username='testuser', password='testpass123')
data = {
'name': 'New Company',
'website': 'http://newcompany.com',
'headquarters': 'New HQ',
'description': 'New Description',
'reason': 'Adding new company',
'source': 'Company website'
}
response = self.client.post(reverse('companies:company_create'), data)
self.assertEqual(response.status_code, 302) # Redirects after submission
self.assertTrue(EditSubmission.objects.filter(
submission_type='CREATE',
changes__name='New Company',
status='NEW'
).exists())
def test_company_create_submission_moderator(self):
"""Test creating a company submission as moderator"""
self.client.login(username='moderator', password='modpass123')
data = {
'name': 'New Company',
'website': 'http://newcompany.com',
'headquarters': 'New HQ',
'description': 'New Description',
'reason': 'Adding new company',
'source': 'Company website'
}
response = self.client.post(reverse('companies:company_create'), data)
self.assertEqual(response.status_code, 302) # Redirects after submission
submission = EditSubmission.objects.get(
submission_type='CREATE',
changes__name='New Company'
)
self.assertEqual(submission.status, 'APPROVED')
self.assertEqual(submission.handled_by, self.moderator)
def test_company_photo_submission(self):
"""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;'
image = SimpleUploadedFile('test.gif', image_content, content_type='image/gif')
data = {
'photo': image,
'caption': 'Test Photo',
'date_taken': '2024-01-01'
}
response = 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
).exists())
class ManufacturerViewTests(TestCase):
def setUp(self):
self.client = Client()
self.user = User.objects.create_user(
username='testuser',
email='test@example.com',
password='testpass123'
)
self.moderator = User.objects.create_user(
username='moderator',
email='moderator@example.com',
password='modpass123',
role='MODERATOR'
)
self.manufacturer = Manufacturer.objects.create(
name='Test Manufacturer',
website='http://example.com',
headquarters='Test HQ',
description='Test Description'
)
self.location = Location.objects.create(
content_type=ContentType.objects.get_for_model(Manufacturer),
object_id=self.manufacturer.pk,
name='Test Manufacturer HQ',
location_type='business',
street_address='123 Manufacturer St',
city='Manufacturer City',
state='MS',
country='Test Country',
postal_code='12345',
point=Point(-118.2437, 34.0522)
)
def test_manufacturer_list_view(self):
"""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):
"""Test manufacturer list view with search"""
response = self.client.get(reverse('companies:manufacturer_list') + '?search=Test')
self.assertEqual(response.status_code, 200)
self.assertContains(response, self.manufacturer.name)
response = self.client.get(reverse('companies:manufacturer_list') + '?search=NonExistent')
self.assertEqual(response.status_code, 200)
self.assertNotContains(response, self.manufacturer.name)
def test_manufacturer_list_view_with_country_filter(self):
"""Test manufacturer list view with country filter"""
response = self.client.get(reverse('companies:manufacturer_list') + '?country=Test Country')
self.assertEqual(response.status_code, 200)
self.assertContains(response, self.manufacturer.name)
response = self.client.get(reverse('companies:manufacturer_list') + '?country=NonExistent')
self.assertEqual(response.status_code, 200)
self.assertNotContains(response, self.manufacturer.name)
def test_manufacturer_detail_view(self):
"""Test manufacturer detail view"""
response = self.client.get(
reverse('companies:manufacturer_detail', kwargs={'slug': self.manufacturer.slug})
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, self.manufacturer.name)
self.assertContains(response, self.manufacturer.website)
self.assertContains(response, self.manufacturer.headquarters)
def test_manufacturer_detail_view_invalid_slug(self):
"""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):
"""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):
"""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):
"""Test creating a manufacturer submission as regular user"""
self.client.login(username='testuser', password='testpass123')
data = {
'name': 'New Manufacturer',
'website': 'http://newmanufacturer.com',
'headquarters': 'New HQ',
'description': 'New Description',
'reason': 'Adding new manufacturer',
'source': 'Manufacturer website'
}
response = self.client.post(reverse('companies:manufacturer_create'), data)
self.assertEqual(response.status_code, 302) # Redirects after submission
self.assertTrue(EditSubmission.objects.filter(
submission_type='CREATE',
changes__name='New Manufacturer',
status='NEW'
).exists())
def test_manufacturer_create_submission_moderator(self):
"""Test creating a manufacturer submission as moderator"""
self.client.login(username='moderator', password='modpass123')
data = {
'name': 'New Manufacturer',
'website': 'http://newmanufacturer.com',
'headquarters': 'New HQ',
'description': 'New Description',
'reason': 'Adding new manufacturer',
'source': 'Manufacturer website'
}
response = self.client.post(reverse('companies:manufacturer_create'), data)
self.assertEqual(response.status_code, 302) # Redirects after submission
submission = EditSubmission.objects.get(
submission_type='CREATE',
changes__name='New Manufacturer'
)
self.assertEqual(submission.status, 'APPROVED')
self.assertEqual(submission.handled_by, self.moderator)
def test_manufacturer_photo_submission(self):
"""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;'
image = SimpleUploadedFile('test.gif', image_content, content_type='image/gif')
data = {
'photo': image,
'caption': 'Test Photo',
'date_taken': '2024-01-01'
}
response = 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
).exists())

View File

@@ -4,15 +4,19 @@ from . import views
app_name = 'companies' app_name = 'companies'
urlpatterns = [ urlpatterns = [
# Company URLs # List views first
path('', views.CompanyListView.as_view(), name='company_list'), path('', views.CompanyListView.as_view(), name='company_list'),
path('create/', views.CompanyCreateView.as_view(), name='company_create'),
path('<slug:slug>/edit/', views.CompanyUpdateView.as_view(), name='company_edit'),
path('<slug:slug>/', views.CompanyDetailView.as_view(), name='company_detail'),
# Manufacturer URLs
path('manufacturers/', views.ManufacturerListView.as_view(), name='manufacturer_list'), path('manufacturers/', views.ManufacturerListView.as_view(), name='manufacturer_list'),
# Create views
path('create/', views.CompanyCreateView.as_view(), name='company_create'),
path('manufacturers/create/', views.ManufacturerCreateView.as_view(), name='manufacturer_create'), path('manufacturers/create/', views.ManufacturerCreateView.as_view(), name='manufacturer_create'),
# Update views
path('<slug:slug>/edit/', views.CompanyUpdateView.as_view(), name='company_edit'),
path('manufacturers/<slug:slug>/edit/', views.ManufacturerUpdateView.as_view(), name='manufacturer_edit'), path('manufacturers/<slug:slug>/edit/', views.ManufacturerUpdateView.as_view(), name='manufacturer_edit'),
# Detail views last (to avoid conflicts with other URL patterns)
path('<slug:slug>/', views.CompanyDetailView.as_view(), name='company_detail'),
path('manufacturers/<slug:slug>/', views.ManufacturerDetailView.as_view(), name='manufacturer_detail'), path('manufacturers/<slug:slug>/', views.ManufacturerDetailView.as_view(), name='manufacturer_detail'),
] ]

View File

@@ -4,16 +4,170 @@ from django.urls import reverse
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.contrib import messages from django.contrib import messages
from django.http import HttpResponseRedirect from django.http import HttpResponseRedirect, Http404, JsonResponse
from django.db.models import Count, Sum from django.db.models import Count, Sum, Q
from .models import Company, Manufacturer from .models import Company, Manufacturer
from .forms import CompanyForm, ManufacturerForm from .forms import CompanyForm, ManufacturerForm
from rides.models import Ride from rides.models import Ride
from parks.models import Park from parks.models import Park
from location.models import Location
from core.views import SlugRedirectMixin from core.views import SlugRedirectMixin
from moderation.mixins import EditSubmissionMixin, PhotoSubmissionMixin, HistoryMixin from moderation.mixins import EditSubmissionMixin, PhotoSubmissionMixin, HistoryMixin
from moderation.models import EditSubmission from moderation.models import EditSubmission
# List Views
class CompanyListView(ListView):
model = 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:
# 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')
def get_context_data(self, **kwargs):
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', '')
return context
class ManufacturerListView(ListView):
model = 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:
# 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')
def get_context_data(self, **kwargs):
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'
).count()
# Add filter values to context
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
template_name = 'companies/company_detail.html'
context_object_name = 'company'
def get_object(self, queryset=None):
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")
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
parks = Park.objects.filter(
owner=self.object
).select_related('owner')
context['parks'] = parks
context['total_rides'] = Ride.objects.filter(park__in=parks).count()
return context
def get_redirect_url_pattern(self):
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)
class ManufacturerDetailView(SlugRedirectMixin, EditSubmissionMixin, PhotoSubmissionMixin, HistoryMixin, DetailView):
model = Manufacturer
template_name = 'companies/manufacturer_detail.html'
context_object_name = 'manufacturer'
def get_object(self, queryset=None):
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")
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
rides = Ride.objects.filter(
manufacturer=self.object
).select_related('park', 'coaster_stats')
context['rides'] = rides
context['coaster_count'] = rides.filter(category='ROLLER_COASTER').count()
context['parks_count'] = rides.values('park').distinct().count()
return context
def get_redirect_url_pattern(self):
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)
# Create Views
class CompanyCreateView(LoginRequiredMixin, CreateView): class CompanyCreateView(LoginRequiredMixin, CreateView):
model = Company model = Company
form_class = CompanyForm form_class = CompanyForm
@@ -48,6 +202,41 @@ class CompanyCreateView(LoginRequiredMixin, CreateView):
def get_success_url(self): def get_success_url(self):
return reverse('companies:company_detail', kwargs={'slug': self.object.slug}) return reverse('companies:company_detail', kwargs={'slug': self.object.slug})
class ManufacturerCreateView(LoginRequiredMixin, CreateView):
model = Manufacturer
form_class = ManufacturerForm
template_name = 'companies/manufacturer_form.html'
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', '')
)
# 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'))
def get_success_url(self):
return reverse('companies:manufacturer_detail', kwargs={'slug': self.object.slug})
# Update Views
class CompanyUpdateView(LoginRequiredMixin, UpdateView): class CompanyUpdateView(LoginRequiredMixin, UpdateView):
model = Company model = Company
form_class = CompanyForm form_class = CompanyForm
@@ -87,40 +276,6 @@ class CompanyUpdateView(LoginRequiredMixin, UpdateView):
def get_success_url(self): def get_success_url(self):
return reverse('companies:company_detail', kwargs={'slug': self.object.slug}) return reverse('companies:company_detail', kwargs={'slug': self.object.slug})
class ManufacturerCreateView(LoginRequiredMixin, CreateView):
model = Manufacturer
form_class = ManufacturerForm
template_name = 'companies/manufacturer_form.html'
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', '')
)
# 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'))
def get_success_url(self):
return reverse('companies:manufacturer_detail', kwargs={'slug': self.object.slug})
class ManufacturerUpdateView(LoginRequiredMixin, UpdateView): class ManufacturerUpdateView(LoginRequiredMixin, UpdateView):
model = Manufacturer model = Manufacturer
form_class = ManufacturerForm form_class = ManufacturerForm
@@ -159,111 +314,3 @@ class ManufacturerUpdateView(LoginRequiredMixin, UpdateView):
def get_success_url(self): def get_success_url(self):
return reverse('companies:manufacturer_detail', kwargs={'slug': self.object.slug}) return reverse('companies:manufacturer_detail', kwargs={'slug': self.object.slug})
class CompanyDetailView(SlugRedirectMixin, EditSubmissionMixin, PhotoSubmissionMixin, HistoryMixin, DetailView):
model = Company
template_name = 'companies/company_detail.html'
context_object_name = 'company'
def get_object(self, queryset=None):
if queryset is None:
queryset = self.get_queryset()
slug = self.kwargs.get(self.slug_url_kwarg)
# Try to get by current or historical slug
return self.model.get_by_slug(slug)[0]
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
parks = Park.objects.filter(
owner=self.object
).select_related('owner')
context['parks'] = parks
context['total_rides'] = Ride.objects.filter(park__in=parks).count()
return context
def get_redirect_url_pattern(self):
return 'company_detail'
class ManufacturerDetailView(SlugRedirectMixin, EditSubmissionMixin, PhotoSubmissionMixin, HistoryMixin, DetailView):
model = Manufacturer
template_name = 'companies/manufacturer_detail.html'
context_object_name = 'manufacturer'
def get_object(self, queryset=None):
if queryset is None:
queryset = self.get_queryset()
slug = self.kwargs.get(self.slug_url_kwarg)
# Try to get by current or historical slug
return self.model.get_by_slug(slug)[0]
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
rides = Ride.objects.filter(
manufacturer=self.object
).select_related('park', 'coaster_stats')
context['rides'] = rides
context['coaster_count'] = rides.filter(category='ROLLER_COASTER').count()
context['parks_count'] = rides.values('park').distinct().count()
return context
def get_redirect_url_pattern(self):
return 'manufacturer_detail'
class CompanyListView(ListView):
model = 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:
queryset = queryset.filter(headquarters__icontains=country)
# Search by name if specified
search = self.request.GET.get('search')
if search:
queryset = queryset.filter(name__icontains=search)
return queryset.order_by('name')
class ManufacturerListView(ListView):
model = 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:
queryset = queryset.filter(headquarters__icontains=country)
# Search by name if specified
search = self.request.GET.get('search')
if search:
queryset = queryset.filter(name__icontains=search)
return queryset.order_by('name')
def get_context_data(self, **kwargs):
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='RC'
).count()
return context

View File

@@ -1,18 +1,23 @@
from django.shortcuts import redirect from django.shortcuts import redirect
from django.urls import reverse from django.urls import reverse
from django.views.generic import DetailView
class SlugRedirectMixin: class SlugRedirectMixin:
""" """
Mixin that handles redirects for old slugs. Mixin that handles redirects for old slugs.
Requires the model to inherit from SluggedModel. Requires the model to inherit from SluggedModel and view to inherit from DetailView.
""" """
def get(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
# 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 # Get the object using current or historical slug
try: try:
self.object = self.get_object() self.object = self.get_object()
# Check if we used an old slug # Check if we used an old slug
current_slug = kwargs.get(self.slug_url_kwarg) current_slug = kwargs.get(self.slug_url_kwarg)
if current_slug != self.object.slug: if current_slug and current_slug != self.object.slug:
# Get the URL pattern name from the view # Get the URL pattern name from the view
url_pattern = self.get_redirect_url_pattern() url_pattern = self.get_redirect_url_pattern()
# Build kwargs for reverse() # Build kwargs for reverse()
@@ -22,9 +27,9 @@ class SlugRedirectMixin:
reverse(url_pattern, kwargs=reverse_kwargs), reverse(url_pattern, kwargs=reverse_kwargs),
permanent=True permanent=True
) )
return super().get(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)
except self.model.DoesNotExist: except (self.model.DoesNotExist, AttributeError):
return super().get(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)
def get_redirect_url_pattern(self): def get_redirect_url_pattern(self):
""" """

176
location/tests.py Normal file
View File

@@ -0,0 +1,176 @@
from django.test import TestCase
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.contrib.gis.geos import Point
from django.contrib.gis.measure import D
from .models import Location
from companies.models import Company
from parks.models import Park
class LocationModelTests(TestCase):
def setUp(self):
# Create test company
self.company = Company.objects.create(
name='Test Company',
website='http://example.com'
)
# Create test park
self.park = Park.objects.create(
name='Test Park',
owner=self.company,
status='OPERATING'
)
# Create test location for company
self.company_location = Location.objects.create(
content_type=ContentType.objects.get_for_model(Company),
object_id=self.company.pk,
name='Test Company HQ',
location_type='business',
street_address='123 Company St',
city='Company City',
state='CS',
country='Test Country',
postal_code='12345',
point=Point(-118.2437, 34.0522) # Los Angeles coordinates
)
# Create test location for park
self.park_location = Location.objects.create(
content_type=ContentType.objects.get_for_model(Park),
object_id=self.park.pk,
name='Test Park Location',
location_type='park',
street_address='456 Park Ave',
city='Park City',
state='PC',
country='Test Country',
postal_code='67890',
point=Point(-111.8910, 40.7608) # Park City coordinates
)
def test_location_creation(self):
"""Test location instance creation and field values"""
# Test company location
self.assertEqual(self.company_location.name, 'Test Company HQ')
self.assertEqual(self.company_location.location_type, 'business')
self.assertEqual(self.company_location.street_address, '123 Company St')
self.assertEqual(self.company_location.city, 'Company City')
self.assertEqual(self.company_location.state, 'CS')
self.assertEqual(self.company_location.country, 'Test Country')
self.assertEqual(self.company_location.postal_code, '12345')
self.assertIsNotNone(self.company_location.point)
# Test park location
self.assertEqual(self.park_location.name, 'Test Park Location')
self.assertEqual(self.park_location.location_type, 'park')
self.assertEqual(self.park_location.street_address, '456 Park Ave')
self.assertEqual(self.park_location.city, 'Park City')
self.assertEqual(self.park_location.state, 'PC')
self.assertEqual(self.park_location.country, 'Test Country')
self.assertEqual(self.park_location.postal_code, '67890')
self.assertIsNotNone(self.park_location.point)
def test_location_str_representation(self):
"""Test string representation of location"""
expected_company_str = 'Test Company HQ (Company City, Test Country)'
self.assertEqual(str(self.company_location), expected_company_str)
expected_park_str = 'Test Park Location (Park City, Test Country)'
self.assertEqual(str(self.park_location), expected_park_str)
def test_get_formatted_address(self):
"""Test get_formatted_address method"""
expected_address = '123 Company St, Company City, CS, 12345, Test Country'
self.assertEqual(self.company_location.get_formatted_address(), expected_address)
def test_point_coordinates(self):
"""Test point coordinates"""
# Test company location point
self.assertIsNotNone(self.company_location.point)
self.assertAlmostEqual(self.company_location.point.y, 34.0522, places=4) # latitude
self.assertAlmostEqual(self.company_location.point.x, -118.2437, places=4) # longitude
# Test park location point
self.assertIsNotNone(self.park_location.point)
self.assertAlmostEqual(self.park_location.point.y, 40.7608, places=4) # latitude
self.assertAlmostEqual(self.park_location.point.x, -111.8910, places=4) # longitude
def test_coordinates_property(self):
"""Test coordinates property"""
company_coords = self.company_location.coordinates
self.assertIsNotNone(company_coords)
self.assertAlmostEqual(company_coords[0], 34.0522, places=4) # latitude
self.assertAlmostEqual(company_coords[1], -118.2437, places=4) # longitude
park_coords = self.park_location.coordinates
self.assertIsNotNone(park_coords)
self.assertAlmostEqual(park_coords[0], 40.7608, places=4) # latitude
self.assertAlmostEqual(park_coords[1], -111.8910, places=4) # longitude
def test_distance_calculation(self):
"""Test distance_to method"""
distance = self.company_location.distance_to(self.park_location)
self.assertIsNotNone(distance)
self.assertGreater(distance, 0)
def test_nearby_locations(self):
"""Test nearby_locations method"""
# Create another location near the company location
nearby_location = Location.objects.create(
content_type=ContentType.objects.get_for_model(Company),
object_id=self.company.pk,
name='Nearby Location',
location_type='business',
street_address='789 Nearby St',
city='Company City',
country='Test Country',
point=Point(-118.2438, 34.0523) # Very close to company location
)
nearby = self.company_location.nearby_locations(distance_km=1)
self.assertEqual(nearby.count(), 1)
self.assertEqual(nearby.first(), nearby_location)
def test_content_type_relations(self):
"""Test generic relations work correctly"""
# Test company location relation
company_location = Location.objects.get(
content_type=ContentType.objects.get_for_model(Company),
object_id=self.company.pk
)
self.assertEqual(company_location, self.company_location)
# Test park location relation
park_location = Location.objects.get(
content_type=ContentType.objects.get_for_model(Park),
object_id=self.park.pk
)
self.assertEqual(park_location, self.park_location)
def test_location_updates(self):
"""Test location updates"""
# Update company location
self.company_location.street_address = 'Updated Address'
self.company_location.city = 'Updated City'
self.company_location.save()
updated_location = Location.objects.get(pk=self.company_location.pk)
self.assertEqual(updated_location.street_address, 'Updated Address')
self.assertEqual(updated_location.city, 'Updated City')
def test_point_sync_with_lat_lon(self):
"""Test point synchronization with latitude/longitude fields"""
location = Location.objects.create(
content_type=ContentType.objects.get_for_model(Company),
object_id=self.company.pk,
name='Test Sync Location',
location_type='business',
latitude=34.0522,
longitude=-118.2437
)
self.assertIsNotNone(location.point)
self.assertAlmostEqual(location.point.y, 34.0522, places=4)
self.assertAlmostEqual(location.point.x, -118.2437, places=4)

View File

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

View File

@@ -5,6 +5,9 @@ from django.contrib.contenttypes.models import ContentType
from django.utils.text import slugify from django.utils.text import slugify
from django.conf import settings from django.conf import settings
import os import os
from PIL import Image, ExifTags
from PIL.ExifTags import TAGS
from datetime import datetime
from .storage import MediaStorage from .storage import MediaStorage
from rides.models import Ride from rides.models import Ride
from django.utils import timezone from django.utils import timezone
@@ -56,6 +59,7 @@ class Photo(models.Model):
is_approved = models.BooleanField(default=False) # New field for approval status is_approved = models.BooleanField(default=False) # New field for approval status
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
date_taken = models.DateTimeField(null=True, blank=True)
uploaded_by = models.ForeignKey( uploaded_by = models.ForeignKey(
settings.AUTH_USER_MODEL, settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
@@ -77,7 +81,28 @@ class Photo(models.Model):
def __str__(self) -> str: def __str__(self) -> str:
return f"{self.content_type} - {self.content_object} - {self.caption or 'No caption'}" return f"{self.content_type} - {self.content_object} - {self.caption or 'No caption'}"
def extract_exif_date(self) -> Optional[datetime]:
"""Extract the date taken from image EXIF data"""
try:
with Image.open(self.image) as img:
exif = img.getexif()
if exif:
# Find the DateTime tag ID
for tag_id in ExifTags.TAGS:
if ExifTags.TAGS[tag_id] == 'DateTimeOriginal':
if tag_id in exif:
# EXIF dates are typically in format: '2024:02:15 14:30:00'
date_str = exif[tag_id]
return datetime.strptime(date_str, '%Y:%m:%d %H:%M:%S')
return None
except Exception:
return None
def save(self, *args: Any, **kwargs: Any) -> None: def save(self, *args: Any, **kwargs: Any) -> None:
# Extract EXIF date if this is a new photo
if not self.pk and not self.date_taken:
self.date_taken = self.extract_exif_date()
# Set default caption if not provided # Set default caption if not provided
if not self.caption and self.uploaded_by: if not self.caption and self.uploaded_by:
current_time = timezone.now() current_time = timezone.now()

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 B

189
media/tests.py Normal file
View File

@@ -0,0 +1,189 @@
from django.test import TestCase, override_settings
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 datetime import datetime
from PIL import Image, ExifTags
import io
from .models import Photo
from parks.models import Park
User = get_user_model()
class PhotoModelTests(TestCase):
def setUp(self):
# Create a test user
self.user = User.objects.create_user(
username='testuser',
password='testpass123'
)
# Create a test park for photo association
self.park = 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):
"""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
if date_taken:
exif_dict = {
"0th": {},
"Exif": {
ExifTags.Base.DateTimeOriginal: date_taken.strftime("%Y:%m:%d %H:%M:%S").encode()
}
}
image.save(image_io, 'JPEG', exif=exif_dict)
else:
image.save(image_io, 'JPEG')
image_io.seek(0)
return SimpleUploadedFile(
'test.jpg',
image_io.getvalue(),
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_exif_date_extraction(self):
"""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)
photo = Photo.objects.create(
image=image_file,
uploaded_by=self.user,
content_type=self.content_type,
object_id=self.park.pk
)
if photo.date_taken:
self.assertEqual(
photo.date_taken.strftime("%Y-%m-%d %H:%M:%S"),
test_date.strftime("%Y-%m-%d %H:%M:%S")
)
else:
self.skipTest("EXIF data extraction not supported in test environment")
def test_photo_without_exif(self):
"""Test photo upload without EXIF data"""
image_file = self.create_test_image_with_exif() # No date provided
photo = Photo.objects.create(
image=image_file,
uploaded_by=self.user,
content_type=self.content_type,
object_id=self.park.pk
)
self.assertIsNone(photo.date_taken)
def test_default_caption(self):
"""Test default caption 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_prefix = f"Uploaded by {self.user.username} on"
self.assertTrue(photo.caption.startswith(expected_prefix))
def test_primary_photo_toggle(self):
"""Test primary photo functionality"""
# Create two photos
photo1 = Photo.objects.create(
image=SimpleUploadedFile(
'test1.jpg',
b'dummy image data',
content_type='image/jpeg'
),
uploaded_by=self.user,
content_type=self.content_type,
object_id=self.park.pk,
is_primary=True
)
photo2 = Photo.objects.create(
image=SimpleUploadedFile(
'test2.jpg',
b'dummy image data',
content_type='image/jpeg'
),
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):
"""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'
),
uploaded_by=self.user,
content_type=self.content_type,
object_id=self.park.pk,
date_taken=test_date
)
self.assertEqual(photo.date_taken, test_date)

View File

@@ -2,6 +2,7 @@ from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.http import JsonResponse, HttpResponseForbidden from django.http import JsonResponse, HttpResponseForbidden
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.views.generic import DetailView
from django.utils import timezone from django.utils import timezone
import json import json
from .models import EditSubmission, PhotoSubmission from .models import EditSubmission, PhotoSubmission
@@ -49,7 +50,7 @@ class EditSubmissionMixin:
# Auto-approve for moderators and above # Auto-approve for moderators and above
if request.user.role in ['MODERATOR', 'ADMIN', 'SUPERUSER']: if request.user.role in ['MODERATOR', 'ADMIN', 'SUPERUSER']:
obj = submission.auto_approve() obj = submission.approve(request.user)
return JsonResponse({ return JsonResponse({
'status': 'success', 'status': 'success',
'message': 'Changes saved successfully.', 'message': 'Changes saved successfully.',
@@ -119,13 +120,20 @@ class PhotoSubmissionMixin:
'message': 'You must be logged in to upload photos.' 'message': 'You must be logged in to upload photos.'
}, status=403) }, status=403)
try:
obj = self.get_object()
except (AttributeError, self.model.DoesNotExist):
return JsonResponse({
'status': 'error',
'message': 'Invalid object.'
}, status=400)
if not request.FILES.get('photo'): if not request.FILES.get('photo'):
return JsonResponse({ return JsonResponse({
'status': 'error', 'status': 'error',
'message': 'No photo provided.' 'message': 'No photo provided.'
}, status=400) }, status=400)
obj = self.get_object()
content_type = ContentType.objects.get_for_model(obj) content_type = ContentType.objects.get_for_model(obj)
submission = PhotoSubmission( submission = PhotoSubmission(
@@ -184,10 +192,10 @@ class InlineEditMixin:
"""Add inline editing context to views""" """Add inline editing context to views"""
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
if self.request.user.is_authenticated: if hasattr(self, 'request') and self.request.user.is_authenticated:
context['can_edit'] = True context['can_edit'] = True
context['can_auto_approve'] = self.request.user.role in ['MODERATOR', 'ADMIN', 'SUPERUSER'] context['can_auto_approve'] = self.request.user.role in ['MODERATOR', 'ADMIN', 'SUPERUSER']
if hasattr(self, 'get_object'): if isinstance(self, DetailView):
obj = self.get_object() obj = self.get_object()
context['pending_edits'] = EditSubmission.objects.filter( context['pending_edits'] = EditSubmission.objects.filter(
content_type=ContentType.objects.get_for_model(obj), content_type=ContentType.objects.get_for_model(obj),
@@ -200,18 +208,21 @@ class HistoryMixin:
"""Add edit history context to views""" """Add edit history context to views"""
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
obj = self.get_object()
# Get historical records ordered by date # Only add history context for DetailViews
context['history'] = obj.history.all().select_related('history_user').order_by('-history_date') if isinstance(self, DetailView):
obj = self.get_object()
# Get related edit submissions # Get historical records ordered by date
content_type = ContentType.objects.get_for_model(obj) context['history'] = obj.history.all().select_related('history_user').order_by('-history_date')
context['edit_submissions'] = EditSubmission.objects.filter(
content_type=content_type, # Get related edit submissions
object_id=obj.id content_type = ContentType.objects.get_for_model(obj)
).exclude( context['edit_submissions'] = EditSubmission.objects.filter(
status='NEW' content_type=content_type,
).select_related('user', 'handled_by').order_by('-created_at') object_id=obj.id
).exclude(
status='NEW'
).select_related('user', 'handled_by').order_by('-created_at')
return context return context

View File

@@ -203,12 +203,12 @@ class PhotoSubmission(models.Model):
# Create the approved photo # Create the approved photo
Photo.objects.create( Photo.objects.create(
user=self.user, uploaded_by=self.user,
content_type=self.content_type, content_type=self.content_type,
object_id=self.object_id, object_id=self.object_id,
image=self.photo, image=self.photo,
caption=self.caption, caption=self.caption,
date_taken=self.date_taken is_approved=True
) )
self.save() self.save()
@@ -231,12 +231,12 @@ class PhotoSubmission(models.Model):
# Create the approved photo # Create the approved photo
Photo.objects.create( Photo.objects.create(
user=self.user, uploaded_by=self.user,
content_type=self.content_type, content_type=self.content_type,
object_id=self.object_id, object_id=self.object_id,
image=self.photo, image=self.photo,
caption=self.caption, caption=self.caption,
date_taken=self.date_taken is_approved=True
) )
self.save() self.save()

View File

@@ -1,3 +1,331 @@
from django.test import TestCase from django.test import TestCase, Client
from django.urls import reverse
from django.contrib.auth import get_user_model
from django.contrib.auth.models import AnonymousUser
from django.contrib.contenttypes.models import ContentType
from django.core.files.uploadedfile import SimpleUploadedFile
from django.http import JsonResponse, HttpRequest
from django.utils import timezone
from django.utils.datastructures import MultiValueDict
from django.http import QueryDict
from .models import EditSubmission, PhotoSubmission
from .mixins import EditSubmissionMixin, PhotoSubmissionMixin, ModeratorRequiredMixin, AdminRequiredMixin, InlineEditMixin, HistoryMixin
from companies.models import Company
from django.views.generic import DetailView
from django.test import RequestFactory
import json
# Create your tests here. User = get_user_model()
class TestView(EditSubmissionMixin, PhotoSubmissionMixin, InlineEditMixin, HistoryMixin, DetailView):
model = Company
template_name = 'test.html'
pk_url_kwarg = 'pk'
slug_url_kwarg = 'slug'
def get_context_data(self, **kwargs):
if not hasattr(self, 'object'):
self.object = self.get_object()
return super().get_context_data(**kwargs)
def setup(self, request, *args, **kwargs):
super().setup(request, *args, **kwargs)
self.request = request
class ModerationMixinsTests(TestCase):
def setUp(self):
self.client = Client()
self.factory = RequestFactory()
# Create users with different roles
self.user = User.objects.create_user(
username='testuser',
email='test@example.com',
password='testpass123'
)
self.moderator = User.objects.create_user(
username='moderator',
email='moderator@example.com',
password='modpass123',
role='MODERATOR'
)
self.admin = User.objects.create_user(
username='admin',
email='admin@example.com',
password='adminpass123',
role='ADMIN'
)
# Create test company
self.company = Company.objects.create(
name='Test Company',
website='http://example.com',
headquarters='Test HQ',
description='Test Description'
)
def test_edit_submission_mixin_unauthenticated(self):
"""Test edit submission when not logged in"""
view = TestView()
request = self.factory.post(f'/test/{self.company.pk}/')
request.user = AnonymousUser()
view.setup(request, pk=self.company.pk)
view.kwargs = {'pk': self.company.pk}
response = view.handle_edit_submission(request, {})
self.assertIsInstance(response, JsonResponse)
self.assertEqual(response.status_code, 403)
def test_edit_submission_mixin_no_changes(self):
"""Test edit submission with no changes"""
view = TestView()
request = self.factory.post(
f'/test/{self.company.pk}/',
data=json.dumps({}),
content_type='application/json'
)
request.user = self.user
view.setup(request, pk=self.company.pk)
view.kwargs = {'pk': self.company.pk}
response = view.post(request)
self.assertIsInstance(response, JsonResponse)
self.assertEqual(response.status_code, 400)
def test_edit_submission_mixin_invalid_json(self):
"""Test edit submission with invalid JSON"""
view = TestView()
request = self.factory.post(
f'/test/{self.company.pk}/',
data='invalid json',
content_type='application/json'
)
request.user = self.user
view.setup(request, pk=self.company.pk)
view.kwargs = {'pk': self.company.pk}
response = view.post(request)
self.assertIsInstance(response, JsonResponse)
self.assertEqual(response.status_code, 400)
def test_edit_submission_mixin_regular_user(self):
"""Test edit submission as regular user"""
view = TestView()
request = self.factory.post(f'/test/{self.company.pk}/')
request.user = self.user
view.setup(request, pk=self.company.pk)
view.kwargs = {'pk': self.company.pk}
changes = {'name': 'New Name'}
response = view.handle_edit_submission(request, changes, 'Test reason', 'Test source')
self.assertIsInstance(response, JsonResponse)
self.assertEqual(response.status_code, 200)
data = json.loads(response.content.decode())
self.assertFalse(data['auto_approved'])
def test_edit_submission_mixin_moderator(self):
"""Test edit submission as moderator"""
view = TestView()
request = self.factory.post(f'/test/{self.company.pk}/')
request.user = self.moderator
view.setup(request, pk=self.company.pk)
view.kwargs = {'pk': self.company.pk}
changes = {'name': 'New Name'}
response = view.handle_edit_submission(request, changes, 'Test reason', 'Test source')
self.assertIsInstance(response, JsonResponse)
self.assertEqual(response.status_code, 200)
data = json.loads(response.content.decode())
self.assertTrue(data['auto_approved'])
def test_photo_submission_mixin_unauthenticated(self):
"""Test photo submission when not logged in"""
view = TestView()
view.kwargs = {'pk': self.company.pk}
view.object = self.company
request = self.factory.post(
f'/test/{self.company.pk}/',
data={},
format='multipart'
)
request.user = AnonymousUser()
view.setup(request, pk=self.company.pk)
response = view.handle_photo_submission(request)
self.assertIsInstance(response, JsonResponse)
self.assertEqual(response.status_code, 403)
def test_photo_submission_mixin_no_photo(self):
"""Test photo submission with no photo"""
view = TestView()
view.kwargs = {'pk': self.company.pk}
view.object = self.company
request = self.factory.post(
f'/test/{self.company.pk}/',
data={},
format='multipart'
)
request.user = self.user
view.setup(request, pk=self.company.pk)
response = view.handle_photo_submission(request)
self.assertIsInstance(response, JsonResponse)
self.assertEqual(response.status_code, 400)
def test_photo_submission_mixin_regular_user(self):
"""Test photo submission as regular user"""
view = TestView()
view.kwargs = {'pk': self.company.pk}
view.object = self.company
# Create a test photo file
photo = SimpleUploadedFile(
'test.gif',
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;',
content_type='image/gif'
)
request = self.factory.post(
f'/test/{self.company.pk}/',
data={'photo': photo, 'caption': 'Test Photo', 'date_taken': '2024-01-01'},
format='multipart'
)
request.user = self.user
view.setup(request, pk=self.company.pk)
response = view.handle_photo_submission(request)
self.assertIsInstance(response, JsonResponse)
self.assertEqual(response.status_code, 200)
data = json.loads(response.content.decode())
self.assertFalse(data['auto_approved'])
def test_photo_submission_mixin_moderator(self):
"""Test photo submission as moderator"""
view = TestView()
view.kwargs = {'pk': self.company.pk}
view.object = self.company
# Create a test photo file
photo = SimpleUploadedFile(
'test.gif',
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;',
content_type='image/gif'
)
request = self.factory.post(
f'/test/{self.company.pk}/',
data={'photo': photo, 'caption': 'Test Photo', 'date_taken': '2024-01-01'},
format='multipart'
)
request.user = self.moderator
view.setup(request, pk=self.company.pk)
response = view.handle_photo_submission(request)
self.assertIsInstance(response, JsonResponse)
self.assertEqual(response.status_code, 200)
data = json.loads(response.content.decode())
self.assertTrue(data['auto_approved'])
def test_moderator_required_mixin(self):
"""Test moderator required mixin"""
class TestModeratorView(ModeratorRequiredMixin):
def __init__(self):
self.request = None
view = TestModeratorView()
# Test unauthenticated user
request = self.factory.get('/test/')
request.user = AnonymousUser()
view.request = request
self.assertFalse(view.test_func())
# Test regular user
request.user = self.user
view.request = request
self.assertFalse(view.test_func())
# Test moderator
request.user = self.moderator
view.request = request
self.assertTrue(view.test_func())
# Test admin
request.user = self.admin
view.request = request
self.assertTrue(view.test_func())
def test_admin_required_mixin(self):
"""Test admin required mixin"""
class TestAdminView(AdminRequiredMixin):
def __init__(self):
self.request = None
view = TestAdminView()
# Test unauthenticated user
request = self.factory.get('/test/')
request.user = AnonymousUser()
view.request = request
self.assertFalse(view.test_func())
# Test regular user
request.user = self.user
view.request = request
self.assertFalse(view.test_func())
# Test moderator
request.user = self.moderator
view.request = request
self.assertFalse(view.test_func())
# Test admin
request.user = self.admin
view.request = request
self.assertTrue(view.test_func())
def test_inline_edit_mixin(self):
"""Test inline edit mixin"""
view = TestView()
view.kwargs = {'pk': self.company.pk}
view.object = self.company
# Test unauthenticated user
request = self.factory.get(f'/test/{self.company.pk}/')
request.user = AnonymousUser()
view.setup(request, pk=self.company.pk)
context = view.get_context_data()
self.assertNotIn('can_edit', context)
# Test regular user
request.user = self.user
view.setup(request, pk=self.company.pk)
context = view.get_context_data()
self.assertTrue(context['can_edit'])
self.assertFalse(context['can_auto_approve'])
# Test moderator
request.user = self.moderator
view.setup(request, pk=self.company.pk)
context = view.get_context_data()
self.assertTrue(context['can_edit'])
self.assertTrue(context['can_auto_approve'])
def test_history_mixin(self):
"""Test history mixin"""
view = TestView()
view.kwargs = {'pk': self.company.pk}
view.object = self.company
request = self.factory.get(f'/test/{self.company.pk}/')
request.user = self.user
view.setup(request, pk=self.company.pk)
# Create some edit submissions
EditSubmission.objects.create(
user=self.user,
content_type=ContentType.objects.get_for_model(Company),
object_id=self.company.id,
submission_type='EDIT',
changes={'name': 'New Name'},
status='APPROVED'
)
context = view.get_context_data()
self.assertIn('history', context)
self.assertIn('edit_submissions', context)
self.assertEqual(len(context['edit_submissions']), 1)

View File

@@ -10,8 +10,8 @@ def forwards_func(apps, schema_editor):
ContentType = apps.get_model("contenttypes", "ContentType") ContentType = apps.get_model("contenttypes", "ContentType")
db_alias = schema_editor.connection.alias db_alias = schema_editor.connection.alias
# Get content type for Park model # Get or create content type for Park model
park_content_type = ContentType.objects.db_manager(db_alias).get( park_content_type, created = ContentType.objects.db_manager(db_alias).get_or_create(
app_label='parks', app_label='parks',
model='park' model='park'
) )
@@ -42,8 +42,8 @@ def reverse_func(apps, schema_editor):
ContentType = apps.get_model("contenttypes", "ContentType") ContentType = apps.get_model("contenttypes", "ContentType")
db_alias = schema_editor.connection.alias db_alias = schema_editor.connection.alias
# Get content type for Park model # Get or create content type for Park model
park_content_type = ContentType.objects.db_manager(db_alias).get( park_content_type, created = ContentType.objects.db_manager(db_alias).get_or_create(
app_label='parks', app_label='parks',
model='park' model='park'
) )

View File

@@ -0,0 +1,10 @@
from django import template
register = template.Library()
@register.filter
def has_reviewed_park(user, park):
"""Check if a user has reviewed a park"""
if not user.is_authenticated:
return False
return park.reviews.filter(user=user).exists()

View File

@@ -1,3 +1,194 @@
from django.test import TestCase from django.test import TestCase, Client
from django.urls import reverse
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 .models import Park, ParkArea
from companies.models import Company
from location.models import Location
# Create your tests here. User = get_user_model()
class ParkModelTests(TestCase):
@classmethod
def setUpTestData(cls):
# Create test user
cls.user = User.objects.create_user(
username='testuser',
email='test@example.com',
password='testpass123'
)
# Create test company
cls.company = Company.objects.create(
name='Test Company',
website='http://example.com'
)
# Create test park
cls.park = Park.objects.create(
name='Test Park',
owner=cls.company,
status='OPERATING',
website='http://testpark.com'
)
# 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
)
def test_park_creation(self):
"""Test park instance creation and field values"""
self.assertEqual(self.park.name, 'Test Park')
self.assertEqual(self.park.owner, self.company)
self.assertEqual(self.park.status, 'OPERATING')
self.assertEqual(self.park.website, 'http://testpark.com')
self.assertTrue(self.park.slug)
def test_park_str_representation(self):
"""Test string representation of park"""
self.assertEqual(str(self.park), 'Test Park')
def test_park_location(self):
"""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')
def test_park_coordinates(self):
"""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
def test_park_formatted_location(self):
"""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):
# Create test company
self.company = Company.objects.create(
name='Test Company',
website='http://example.com'
)
# Create test park
self.park = Park.objects.create(
name='Test Park',
owner=self.company,
status='OPERATING'
)
# 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)
)
# Create test area
self.area = ParkArea.objects.create(
park=self.park,
name='Test Area',
description='Test Description'
)
def test_area_creation(self):
"""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):
"""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):
"""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):
self.client = Client()
self.user = User.objects.create_user(
username='testuser',
email='test@example.com',
password='testpass123'
)
self.company = Company.objects.create(
name='Test Company',
website='http://example.com'
)
self.park = Park.objects.create(
name='Test Park',
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)
)
def test_park_list_view(self):
"""Test park list view"""
response = self.client.get(reverse('parks:park_list'))
self.assertEqual(response.status_code, 200)
self.assertContains(response, self.park.name)
def test_park_detail_view(self):
"""Test park detail view"""
response = 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')
def test_park_area_detail_view(self):
"""Test park area detail view"""
area = ParkArea.objects.create(
park=self.park,
name='Test Area'
)
response = 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)

View File

@@ -1,107 +1,141 @@
{% extends 'base/base.html' %} {% extends "base/base.html" %}
{% load static %} {% load static %}
{% block title %}Ride Manufacturers - ThrillWiki{% endblock %} {% block title %}Manufacturers - ThrillWiki{% endblock %}
{% block content %} {% block content %}
<div class="container mx-auto px-4"> <div class="container px-4 mx-auto sm:px-6 lg:px-8">
<div class="flex justify-between items-center mb-6"> <!-- Header -->
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Ride Manufacturers</h1> <div class="flex flex-col items-start justify-between gap-4 mb-6 sm:flex-row sm:items-center">
<h1 class="text-2xl font-bold text-gray-900 lg:text-3xl dark:text-white">Manufacturers</h1>
{% if user.is_authenticated %}
<a href="{% url 'companies:manufacturer_create' %}"
class="transition-transform btn-primary hover:scale-105">
<i class="mr-1 fas fa-plus"></i>Add Manufacturer
</a>
{% endif %}
</div> </div>
<!-- Stats --> <!-- Stats -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6"> <div class="grid grid-cols-1 gap-4 mb-6 sm:grid-cols-3">
<div class="bg-white dark:bg-gray-800 shadow rounded-lg p-6 text-center"> <div class="p-4 bg-white rounded-lg shadow dark:bg-gray-800">
<div class="text-3xl font-bold text-blue-600 dark:text-blue-400"> <dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Total Manufacturers</dt>
{{ total_manufacturers }} <dd class="mt-1 text-3xl font-semibold text-gray-900 dark:text-white">{{ total_manufacturers }}</dd>
</div>
<div class="text-gray-600 dark:text-gray-400 mt-1">Manufacturers</div>
</div> </div>
<div class="p-4 bg-white rounded-lg shadow dark:bg-gray-800">
<div class="bg-white dark:bg-gray-800 shadow rounded-lg p-6 text-center"> <dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Total Rides</dt>
<div class="text-3xl font-bold text-blue-600 dark:text-blue-400"> <dd class="mt-1 text-3xl font-semibold text-gray-900 dark:text-white">{{ total_rides }}</dd>
{{ total_rides }}
</div>
<div class="text-gray-600 dark:text-gray-400 mt-1">Total Rides</div>
</div> </div>
<div class="p-4 bg-white rounded-lg shadow dark:bg-gray-800">
<div class="bg-white dark:bg-gray-800 shadow rounded-lg p-6 text-center"> <dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Total Roller Coasters</dt>
<div class="text-3xl font-bold text-blue-600 dark:text-blue-400"> <dd class="mt-1 text-3xl font-semibold text-gray-900 dark:text-white">{{ total_roller_coasters }}</dd>
{{ total_roller_coasters }}
</div>
<div class="text-gray-600 dark:text-gray-400 mt-1">Roller Coasters</div>
</div> </div>
</div> </div>
<!-- Filters --> <!-- Search and Filter -->
<div class="bg-white dark:bg-gray-800 shadow rounded-lg p-4 mb-6"> <div class="p-4 mb-6 bg-white rounded-lg shadow dark:bg-gray-800">
<form method="get" class="grid grid-cols-1 md:grid-cols-3 gap-4"> <form method="get" class="flex flex-col gap-4 sm:flex-row sm:items-end">
<div> <div class="flex-1">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Search</label> <label for="search" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">Search</label>
<input type="text" name="search" value="{{ request.GET.search }}" <input type="text"
class="form-input w-full" placeholder="Search manufacturers..."> name="search"
id="search"
value="{{ request.GET.search }}"
placeholder="Search manufacturers..."
class="w-full px-3 py-2 border rounded-lg dark:bg-gray-700 dark:border-gray-600 dark:text-white">
</div> </div>
<div> <div class="flex-1">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Country</label> <label for="country" class="block mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">Country</label>
<input type="text" name="country" value="{{ request.GET.country }}" <input type="text"
class="form-input w-full" placeholder="Filter by country..."> name="country"
</div> id="country"
<div class="flex items-end"> value="{{ request.GET.country }}"
<button type="submit" class="btn-primary w-full">Apply Filters</button> placeholder="Filter by country..."
class="w-full px-3 py-2 border rounded-lg dark:bg-gray-700 dark:border-gray-600 dark:text-white">
</div> </div>
<button type="submit" class="btn-primary">
<i class="mr-1 fas fa-search"></i>Search
</button>
{% if request.GET.search or request.GET.country %}
<a href="{% url 'companies:manufacturer_list' %}" class="btn-secondary">
<i class="mr-1 fas fa-times"></i>Clear
</a>
{% endif %}
</form> </form>
</div> </div>
<!-- Manufacturers Grid --> <!-- Manufacturers Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> {% if manufacturers %}
<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{% for manufacturer in manufacturers %} {% for manufacturer in manufacturers %}
<div class="bg-white dark:bg-gray-800 shadow rounded-lg overflow-hidden"> <div class="p-6 transition-transform bg-white rounded-lg shadow hover:scale-[1.02] dark:bg-gray-800">
<div class="p-4"> <h2 class="mb-2 text-xl font-semibold">
<h3 class="text-xl font-semibold mb-2"> <a href="{% url 'companies:manufacturer_detail' manufacturer.slug %}"
<a href="{% url 'companies:manufacturer_detail' manufacturer.slug %}" class="text-gray-900 hover:text-blue-600 dark:text-white dark:hover:text-blue-400">
class="text-blue-600 dark:text-blue-400 hover:underline"> {{ manufacturer.name }}
{{ manufacturer.name }} </a>
</a> </h2>
</h3> {% if manufacturer.headquarters %}
{% if manufacturer.headquarters %} <div class="flex items-center mb-2 text-gray-600 dark:text-gray-400">
<p class="text-gray-600 dark:text-gray-400 mb-2">{{ manufacturer.headquarters }}</p> <i class="mr-2 fas fa-building"></i>
{% endif %} {{ manufacturer.headquarters }}
<div class="text-sm text-gray-500 dark:text-gray-400">
{{ manufacturer.rides.count }} rides manufactured
</div>
</div>
</div> </div>
{% empty %} {% endif %}
<div class="col-span-full text-center py-8"> {% if manufacturer.website %}
<p class="text-gray-500 dark:text-gray-400">No manufacturers found matching your criteria.</p> <div class="flex items-center mb-4 text-gray-600 dark:text-gray-400">
<i class="mr-2 fas fa-globe"></i>
<a href="{{ manufacturer.website }}"
class="text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
target="_blank" rel="noopener noreferrer">
Website
<i class="ml-1 text-xs fas fa-external-link-alt"></i>
</a>
</div> </div>
{% endif %}
<div class="flex flex-wrap gap-2">
{% if manufacturer.total_rides %}
<span class="px-2 py-1 text-sm font-medium text-blue-800 bg-blue-100 rounded-full dark:bg-blue-700 dark:text-blue-50">
{{ manufacturer.total_rides }} Rides
</span>
{% endif %}
{% if manufacturer.total_roller_coasters %}
<span class="px-2 py-1 text-sm font-medium text-green-800 bg-green-100 rounded-full dark:bg-green-700 dark:text-green-50">
{{ manufacturer.total_roller_coasters }} Coasters
</span>
{% endif %}
</div>
</div>
{% endfor %} {% endfor %}
</div> </div>
{% else %}
<div class="p-6 text-center bg-white rounded-lg shadow dark:bg-gray-800">
<p class="text-gray-500 dark:text-gray-400">No manufacturers found.</p>
</div>
{% endif %}
<!-- Pagination --> <!-- Pagination -->
{% if is_paginated %} {% if is_paginated %}
<div class="flex justify-center mt-6"> <div class="flex justify-center mt-6">
<nav class="inline-flex rounded-md shadow"> <nav class="inline-flex rounded-md shadow">
{% if page_obj.has_previous %} {% if page_obj.has_previous %}
<a href="?page={{ page_obj.previous_page_number }}{% if request.GET.search %}&search={{ request.GET.search }}{% endif %}{% if request.GET.country %}&country={{ request.GET.country }}{% endif %}" <a href="?page={{ page_obj.previous_page_number }}"
class="pagination-link">Previous</a> class="px-3 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 rounded-l-md hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400 dark:hover:bg-gray-700">
{% endif %} Previous
</a>
{% endif %}
{% for num in page_obj.paginator.page_range %} <span class="px-3 py-2 text-sm font-medium text-gray-700 bg-white border-t border-b border-gray-300 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300">
{% if page_obj.number == num %} Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}
<span class="pagination-current">{{ num }}</span> </span>
{% elif num > page_obj.number|add:'-3' and num < page_obj.number|add:'3' %}
<a href="?page={{ num }}{% if request.GET.search %}&search={{ request.GET.search }}{% endif %}{% if request.GET.country %}&country={{ request.GET.country }}{% endif %}"
class="pagination-link">{{ num }}</a>
{% endif %}
{% endfor %}
{% if page_obj.has_next %} {% if page_obj.has_next %}
<a href="?page={{ page_obj.next_page_number }}{% if request.GET.search %}&search={{ request.GET.search }}{% endif %}{% if request.GET.country %}&country={{ request.GET.country }}{% endif %}" <a href="?page={{ page_obj.next_page_number }}"
class="pagination-link">Next</a> class="px-3 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 rounded-r-md hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400 dark:hover:bg-gray-700">
{% endif %} Next
</nav> </a>
</div> {% endif %}
</nav>
</div>
{% endif %} {% endif %}
</div> </div>
{% endblock %} {% endblock %}

View File

@@ -6,7 +6,9 @@
{ {
id: {{ photo.id }}, id: {{ photo.id }},
url: '{{ photo.image.url }}', url: '{{ photo.image.url }}',
caption: '{{ photo.caption|default:""|escapejs }}' caption: '{{ photo.caption|default:""|escapejs }}',
date_taken: '{{ photo.date_taken|date:"F j, Y g:i A"|default:""|escapejs }}',
uploaded_by: '{{ photo.uploaded_by.username|default:""|escapejs }}'
}{% if not forloop.last %},{% endif %} }{% if not forloop.last %},{% endif %}
{% endfor %} {% endfor %}
], ],
@@ -90,15 +92,35 @@
<i class="text-2xl fas fa-times"></i> <i class="text-2xl fas fa-times"></i>
</button> </button>
<!-- Photo --> <!-- Photo Container -->
<img :src="fullscreenPhoto?.url" <div class="relative">
:alt="fullscreenPhoto?.caption || ''" <img :src="fullscreenPhoto?.url"
class="max-h-[90vh] w-auto mx-auto rounded-lg"> :alt="fullscreenPhoto?.caption || ''"
class="max-h-[90vh] w-auto mx-auto rounded-lg">
<!-- Caption --> <!-- Photo Info Overlay -->
<div x-show="fullscreenPhoto?.caption" <div class="absolute bottom-0 left-0 right-0 p-4 text-white bg-black bg-opacity-50 rounded-b-lg">
class="mt-4 text-center text-white" <!-- Caption -->
x-text="fullscreenPhoto?.caption"> <div x-show="fullscreenPhoto?.caption"
class="mb-2 text-lg font-medium"
x-text="fullscreenPhoto?.caption">
</div>
<!-- Photo Details -->
<div class="flex flex-wrap gap-4 text-sm">
<!-- Uploaded By -->
<div x-show="fullscreenPhoto?.uploaded_by" class="flex items-center">
<i class="mr-2 fas fa-user"></i>
<span x-text="fullscreenPhoto?.uploaded_by"></span>
</div>
<!-- Date Taken -->
<div x-show="fullscreenPhoto?.date_taken" class="flex items-center">
<i class="mr-2 fas fa-calendar"></i>
<span x-text="fullscreenPhoto?.date_taken"></span>
</div>
</div>
</div>
</div> </div>
<!-- Actions --> <!-- Actions -->

View File

@@ -0,0 +1,90 @@
{% extends "base/base.html" %}
{% load static %}
{% block title %}{{ area.name }} - {{ area.park.name }} - ThrillWiki{% endblock %}
{% block content %}
<div class="container px-4 mx-auto sm:px-6 lg:px-8">
<!-- Breadcrumb -->
<nav class="flex mb-4 text-sm" aria-label="Breadcrumb">
<ol class="inline-flex items-center space-x-1 md:space-x-3">
<li>
<a href="{% url 'parks:park_detail' area.park.slug %}"
class="text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300">
{{ area.park.name }}
</a>
</li>
<li>
<div class="flex items-center">
<i class="mx-2 text-gray-400 fas fa-chevron-right"></i>
<span class="text-gray-500 dark:text-gray-400">{{ area.name }}</span>
</div>
</li>
</ol>
</nav>
<!-- Area Header -->
<div class="p-6 mb-6 bg-white rounded-lg shadow dark:bg-gray-800">
<div class="flex items-center justify-between mb-4">
<h1 class="text-2xl font-bold text-gray-900 lg:text-3xl dark:text-white">{{ area.name }}</h1>
{% if user.is_authenticated %}
<a href="#" class="transition-transform btn-secondary hover:scale-105">
<i class="mr-1 fas fa-pencil-alt"></i>Edit
</a>
{% endif %}
</div>
{% if area.description %}
<div class="prose dark:prose-invert max-w-none">
{{ area.description|linebreaks }}
</div>
{% endif %}
{% if area.opening_date or area.closing_date %}
<div class="mt-4 space-y-2">
{% if area.opening_date %}
<div class="flex items-center text-gray-600 dark:text-gray-400">
<i class="mr-2 fas fa-calendar-plus"></i>
<span>Opened: {{ area.opening_date }}</span>
</div>
{% endif %}
{% if area.closing_date %}
<div class="flex items-center text-gray-600 dark:text-gray-400">
<i class="mr-2 fas fa-calendar-minus"></i>
<span>Closed: {{ area.closing_date }}</span>
</div>
{% endif %}
</div>
{% endif %}
</div>
<!-- Rides in this Area -->
{% if area.rides.exists %}
<div class="p-6 bg-white rounded-lg shadow dark:bg-gray-800">
<h2 class="mb-4 text-xl font-semibold text-gray-900 dark:text-white">Rides & Attractions</h2>
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{% for ride in area.rides.all %}
<div class="p-4 transition-colors rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700">
<a href="{% url 'parks:rides:ride_detail' area.park.slug ride.slug %}" class="block">
<h3 class="mb-1 font-semibold text-gray-900 dark:text-white">{{ ride.name }}</h3>
<span class="text-blue-800 bg-blue-100 status-badge dark:bg-blue-700 dark:text-blue-50">
{{ ride.get_category_display }}
</span>
{% if ride.average_rating %}
<span class="flex items-center text-yellow-800 bg-yellow-100 status-badge dark:bg-yellow-600 dark:text-yellow-50">
<span class="mr-1 text-yellow-500 dark:text-yellow-200"></span>
{{ ride.average_rating|floatformat:1 }}/10
</span>
{% endif %}
</a>
</div>
{% endfor %}
</div>
</div>
{% else %}
<div class="p-6 bg-white rounded-lg shadow dark:bg-gray-800">
<p class="text-gray-500 dark:text-gray-400">No rides or attractions listed in this area yet.</p>
</div>
{% endif %}
</div>
{% endblock %}

View File

@@ -1,5 +1,6 @@
{% extends "base/base.html" %} {% extends "base/base.html" %}
{% load static %} {% load static %}
{% load park_tags %}
{% block title %}{{ park.name }} - ThrillWiki{% endblock %} {% block title %}{{ park.name }} - ThrillWiki{% endblock %}
@@ -41,7 +42,7 @@
<i class="mr-1 fas fa-star"></i>Add Review <i class="mr-1 fas fa-star"></i>Add Review
</a> </a>
{% else %} {% else %}
{% if user.has_reviewed_park(park) %} {% if user|has_reviewed_park:park %}
<a href="{% url 'reviews:edit_review' park.slug %}" <a href="{% url 'reviews:edit_review' park.slug %}"
class="transition-transform btn-secondary hover:scale-105"> class="transition-transform btn-secondary hover:scale-105">
<i class="mr-1 fas fa-star"></i>Edit Review <i class="mr-1 fas fa-star"></i>Edit Review

84
tests/README.md Normal file
View File

@@ -0,0 +1,84 @@
# ThrillWiki Test Suite
This directory contains the comprehensive test suite for ThrillWiki, including unit tests and integration tests for all major components of the system.
## Running Tests
To run the complete test suite with coverage reporting:
```bash
python tests/test_runner.py
```
This will:
1. Run all tests across all apps
2. Generate a coverage report in the terminal
3. Create a detailed HTML coverage report in `tests/coverage_html/`
## Viewing Coverage Reports
There are two ways to view the coverage reports:
1. Terminal Report: Shows a quick overview of test coverage directly in your terminal after running the tests.
2. HTML Report: A detailed, interactive report showing line-by-line coverage that can be accessed in two ways:
- Directly open `tests/coverage_html/index.html` in your browser
- Visit `http://localhost:8000/coverage/` when running the development server (only available in DEBUG mode)
The HTML report provides:
- Line-by-line coverage analysis
- Branch coverage information
- Missing lines highlighting
- Interactive file browser
- Detailed statistics per module
## Test Structure
The test suite is organized by app, with each app having its own test file:
- `parks/tests.py`: Tests for park-related functionality
- `companies/tests.py`: Tests for company and manufacturer models
- `location/tests.py`: Tests for location functionality and GeoDjango features
- Additional test files in other app directories
## Writing New Tests
When adding new features or modifying existing ones, please ensure:
1. All new code is covered by tests
2. Tests follow the existing pattern in related test files
3. Both positive and negative test cases are included
4. Edge cases are considered and tested
## Test Categories
The test suite includes:
- Model Tests: Verify model creation, validation, and methods
- View Tests: Test view responses and template rendering
- Form Tests: Validate form processing and validation
- Integration Tests: Test interactions between components
## Continuous Integration
These tests are run automatically on:
- Pull request creation
- Merges to main branch
- Release tagging
## Troubleshooting
If tests fail:
1. Check the error message and stack trace
2. Verify test database settings
3. Ensure all required dependencies are installed
4. Check for any pending migrations
For any issues, please create a ticket in the issue tracker.
## Development Tips
- Run the development server with `python manage.py runserver` to access the coverage reports at `http://localhost:8000/coverage/`
- Coverage reports are only served in development mode (when DEBUG=True)
- The coverage directory is automatically created when running tests
- Reports are updated each time you run the test suite

133
tests/test_runner.py Normal file
View File

@@ -0,0 +1,133 @@
#!/usr/bin/env python
import os
import sys
import django
from django.conf import settings
from django.test.runner import DiscoverRunner
import coverage
import unittest
def setup_django():
"""Set up Django test environment"""
# Add the project root directory to Python path
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, project_root)
os***REMOVED***iron.setdefault('DJANGO_SETTINGS_MODULE', 'thrillwiki.settings')
django.setup()
# Use PostGIS for GeoDjango support
settings.DATABASES = {
'default': {
'ENGINE': 'django.contrib.gis.db.backends.postgis',
'NAME': 'test_thrillwiki',
'USER': 'postgres',
'PASSWORD': 'postgres',
'HOST': 'localhost',
'PORT': '5432',
'TEST': {
'NAME': 'test_thrillwiki',
}
}
}
settings.DEBUG = False
# Skip problematic migrations during tests
settings.MIGRATION_MODULES = {
'parks': None,
'companies': None,
'location': None,
'rides': None,
'reviews': None
}
class CustomTestRunner(DiscoverRunner):
def __init__(self, *args, **kwargs):
self.cov = coverage.Coverage(
source=[
'parks',
'companies',
'location',
'rides',
'reviews'
],
omit=[
'*/migrations/*',
'*/management/*',
'*/admin.py',
'*/apps.py',
'manage.py'
]
)
self.cov.start()
super().__init__(*args, **kwargs)
def setup_databases(self, **kwargs):
"""Set up databases and ensure content types are created"""
old_config = super().setup_databases(**kwargs)
# Create necessary content types
from django.contrib.contenttypes.models import ContentType
from parks.models import Park
from companies.models import Company
ContentType.objects.get_or_create(
app_label='parks',
model='park'
)
ContentType.objects.get_or_create(
app_label='companies',
model='company'
)
return old_config
def run_suite(self, suite, **kwargs):
results = super().run_suite(suite, **kwargs)
self.cov.stop()
self.cov.save()
# Print coverage report
print('\nCoverage Report:')
self.cov.report()
# Generate HTML coverage report
html_dir = os.path.join('tests', 'coverage_html')
self.cov.html_report(directory=html_dir)
print(f'\nDetailed HTML coverage report generated in: {html_dir}')
return results
def run_tests():
# Set up Django
setup_django()
# Initialize test runner
test_runner = CustomTestRunner(
verbosity=2,
interactive=True,
keepdb=True
)
# Define test labels for discovery
test_labels = [
'parks.tests',
'companies.tests',
'location.tests',
'rides.tests',
'reviews.tests'
]
# Run tests and collect results
failures = test_runner.run_tests(test_labels)
return failures
if __name__ == '__main__':
# Create tests directory if it doesn't exist
os.makedirs('tests', exist_ok=True)
os.makedirs(os.path.join('tests', 'coverage_html'), exist_ok=True)
# Run tests and exit with appropriate status code
failures = run_tests()
sys.exit(bool(failures))

View File

@@ -2,10 +2,12 @@ from django.contrib import admin
from django.urls import path, include from django.urls import path, include
from django.conf import settings from django.conf import settings
from django.conf.urls.static import static from django.conf.urls.static import static
from django.views.static import serve
from accounts import views as accounts_views from accounts import views as accounts_views
from django.views.generic import TemplateView from django.views.generic import TemplateView
from .views import HomeView, SearchView from .views import HomeView, SearchView
from . import views from . import views
import os
urlpatterns = [ urlpatterns = [
path("admin/", admin.site.urls), path("admin/", admin.site.urls),
@@ -62,5 +64,18 @@ if settings.DEBUG:
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
# Serve test coverage reports in development
coverage_dir = os.path.join(settings.BASE_DIR, 'tests', 'coverage_html')
if os.path.exists(coverage_dir):
urlpatterns += [
path('coverage/', serve, {
'document_root': coverage_dir,
'path': 'index.html'
}),
path('coverage/<path:path>', serve, {
'document_root': coverage_dir,
}),
]
handler404 = "thrillwiki.views.handler404" handler404 = "thrillwiki.views.handler404"
handler500 = "thrillwiki.views.handler500" handler500 = "thrillwiki.views.handler500"