series of tests added with built-in django test support
3
.gitignore
vendored
@@ -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
@@ -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
@@ -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
@@ -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())
|
||||||
@@ -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())
|
||||||
|
|||||||
@@ -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'),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
18
media/migrations/0007_photo_date_taken.py
Normal 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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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()
|
||||||
|
|||||||
BIN
media/submissions/photos/test.gif
Normal file
|
After Width: | Height: | Size: 35 B |
BIN
media/submissions/photos/test_0kKwOne.gif
Normal file
|
After Width: | Height: | Size: 35 B |
BIN
media/submissions/photos/test_2wg3j6L.gif
Normal file
|
After Width: | Height: | Size: 35 B |
BIN
media/submissions/photos/test_4CpBdcl.gif
Normal file
|
After Width: | Height: | Size: 35 B |
BIN
media/submissions/photos/test_5lfNeAh.gif
Normal file
|
After Width: | Height: | Size: 35 B |
BIN
media/submissions/photos/test_7RtdCUN.gif
Normal file
|
After Width: | Height: | Size: 35 B |
BIN
media/submissions/photos/test_86pBpH5.gif
Normal file
|
After Width: | Height: | Size: 35 B |
BIN
media/submissions/photos/test_BrOnx06.gif
Normal file
|
After Width: | Height: | Size: 35 B |
BIN
media/submissions/photos/test_IaqAVL6.gif
Normal file
|
After Width: | Height: | Size: 35 B |
BIN
media/submissions/photos/test_JfXif5A.gif
Normal file
|
After Width: | Height: | Size: 35 B |
BIN
media/submissions/photos/test_KvWaeSY.gif
Normal file
|
After Width: | Height: | Size: 35 B |
BIN
media/submissions/photos/test_PS8HKUX.gif
Normal file
|
After Width: | Height: | Size: 35 B |
BIN
media/submissions/photos/test_U7nTGc5.gif
Normal file
|
After Width: | Height: | Size: 35 B |
BIN
media/submissions/photos/test_Uf25e5j.gif
Normal file
|
After Width: | Height: | Size: 35 B |
BIN
media/submissions/photos/test_VxfclDl.gif
Normal file
|
After Width: | Height: | Size: 35 B |
BIN
media/submissions/photos/test_aNvalWZ.gif
Normal file
|
After Width: | Height: | Size: 35 B |
BIN
media/submissions/photos/test_bdQ64Pw.gif
Normal file
|
After Width: | Height: | Size: 35 B |
BIN
media/submissions/photos/test_cUFi8YR.gif
Normal file
|
After Width: | Height: | Size: 35 B |
BIN
media/submissions/photos/test_cj91lGL.gif
Normal file
|
After Width: | Height: | Size: 35 B |
BIN
media/submissions/photos/test_doROVXr.gif
Normal file
|
After Width: | Height: | Size: 35 B |
BIN
media/submissions/photos/test_ed2OKmf.gif
Normal file
|
After Width: | Height: | Size: 35 B |
BIN
media/submissions/photos/test_iWXuwx6.gif
Normal file
|
After Width: | Height: | Size: 35 B |
BIN
media/submissions/photos/test_llBhZbJ.gif
Normal file
|
After Width: | Height: | Size: 35 B |
BIN
media/submissions/photos/test_mjx2aJb.gif
Normal file
|
After Width: | Height: | Size: 35 B |
BIN
media/submissions/photos/test_o1PpFtd.gif
Normal file
|
After Width: | Height: | Size: 35 B |
BIN
media/submissions/photos/test_rtW6iWX.gif
Normal file
|
After Width: | Height: | Size: 35 B |
BIN
media/submissions/photos/test_uK9fein.gif
Normal file
|
After Width: | Height: | Size: 35 B |
BIN
media/submissions/photos/test_wcxglNf.gif
Normal file
|
After Width: | Height: | Size: 35 B |
189
media/tests.py
Normal 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)
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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'
|
||||||
)
|
)
|
||||||
|
|||||||
10
parks/templatetags/park_tags.py
Normal 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()
|
||||||
195
parks/tests.py
@@ -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)
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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 -->
|
||||||
|
|||||||
90
templates/parks/area_detail.html
Normal 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 %}
|
||||||
@@ -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
@@ -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
@@ -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))
|
||||||
@@ -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"
|
||||||
|
|||||||