Refactor test utilities and enhance ASGI settings

- Cleaned up and standardized assertions in ApiTestMixin for API response validation.
- Updated ASGI settings to use os.environ for setting the DJANGO_SETTINGS_MODULE.
- Removed unused imports and improved formatting in settings.py.
- Refactored URL patterns in urls.py for better readability and organization.
- Enhanced view functions in views.py for consistency and clarity.
- Added .flake8 configuration for linting and style enforcement.
- Introduced type stubs for django-environ to improve type checking with Pylance.
This commit is contained in:
pacnpal
2025-08-20 19:51:59 -04:00
parent 69c07d1381
commit 66ed4347a9
230 changed files with 15094 additions and 11578 deletions

View File

@@ -1 +1 @@
default_app_config = 'media.apps.MediaConfig'
default_app_config = "media.apps.MediaConfig"

View File

@@ -2,18 +2,27 @@ from django.contrib import admin
from django.utils.html import format_html
from .models import Photo
@admin.register(Photo)
class PhotoAdmin(admin.ModelAdmin):
list_display = ('thumbnail_preview', 'content_type', 'content_object', 'caption', 'is_primary', 'created_at')
list_filter = ('content_type', 'is_primary', 'created_at')
search_fields = ('caption', 'alt_text')
readonly_fields = ('thumbnail_preview',)
list_display = (
"thumbnail_preview",
"content_type",
"content_object",
"caption",
"is_primary",
"created_at",
)
list_filter = ("content_type", "is_primary", "created_at")
search_fields = ("caption", "alt_text")
readonly_fields = ("thumbnail_preview",)
def thumbnail_preview(self, obj):
if obj.image:
return format_html(
'<img src="{}" style="max-height: 50px; max-width: 100px;" />',
obj.image.url
obj.image.url,
)
return "No image"
thumbnail_preview.short_description = 'Thumbnail'
thumbnail_preview.short_description = "Thumbnail"

View File

@@ -1,6 +1,7 @@
from django.apps import AppConfig
from django.db.models.signals import post_migrate
def create_photo_permissions(sender, **kwargs):
"""Create custom permissions for photos"""
from django.contrib.auth.models import Permission
@@ -9,24 +10,25 @@ def create_photo_permissions(sender, **kwargs):
content_type = ContentType.objects.get_for_model(Photo)
Permission.objects.get_or_create(
codename='add_photo',
name='Can add photo',
codename="add_photo",
name="Can add photo",
content_type=content_type,
)
Permission.objects.get_or_create(
codename='change_photo',
name='Can change photo',
codename="change_photo",
name="Can change photo",
content_type=content_type,
)
Permission.objects.get_or_create(
codename='delete_photo',
name='Can delete photo',
codename="delete_photo",
name="Can delete photo",
content_type=content_type,
)
class MediaConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'media'
default_auto_field = "django.db.models.BigAutoField"
name = "media"
def ready(self):
post_migrate.connect(create_photo_permissions, sender=self)

View File

@@ -1,8 +1,5 @@
import os
import requests
from django.core.management.base import BaseCommand
from django.core.files import File
from django.core.files.temp import NamedTemporaryFile
from media.models import Photo
from parks.models import Park
from rides.models import Ride
@@ -10,105 +7,133 @@ from django.contrib.contenttypes.models import ContentType
import json
from django.core.files.base import ContentFile
class Command(BaseCommand):
help = 'Download photos from seed data URLs'
help = "Download photos from seed data URLs"
def handle(self, *args, **kwargs):
self.stdout.write('Downloading photos from seed data...')
self.stdout.write("Downloading photos from seed data...")
# Read seed data
with open('parks/management/commands/seed_data.json', 'r') as f:
with open("parks/management/commands/seed_data.json", "r") as f:
seed_data = json.load(f)
park_content_type = ContentType.objects.get_for_model(Park)
ride_content_type = ContentType.objects.get_for_model(Ride)
# Process parks and their photos
for park_data in seed_data['parks']:
for park_data in seed_data["parks"]:
try:
park = Park.objects.get(name=park_data['name'])
park = Park.objects.get(name=park_data["name"])
# Download park photos
for idx, photo_url in enumerate(park_data['photos'], 1):
for idx, photo_url in enumerate(park_data["photos"], 1):
try:
# Download image
self.stdout.write(f'Downloading from URL: {photo_url}')
self.stdout.write(f"Downloading from URL: {photo_url}")
response = requests.get(photo_url, timeout=60)
if response.status_code == 200:
# Delete any existing photos for this park
Photo.objects.filter(
content_type=park_content_type,
object_id=park.id
object_id=park.id,
).delete()
# Create new photo record
photo = Photo(
content_type=park_content_type,
object_id=park.id,
is_primary=idx == 1
is_primary=idx == 1,
)
# Save image content
photo.image.save(
f"{park.slug}_{idx}.jpg",
ContentFile(response.content),
save=False
save=False,
)
photo.save()
self.stdout.write(f'Downloaded photo for {park.name}: {photo.image.name}')
self.stdout.write(f'Database record created with ID: {photo.id}')
self.stdout.write(
f"Downloaded photo for {
park.name}: {
photo.image.name}"
)
self.stdout.write(
f"Database record created with ID: {photo.id}"
)
else:
self.stdout.write(f'Error downloading image. Status code: {response.status_code}')
self.stdout.write(
f"Error downloading image. Status code: {
response.status_code}"
)
except Exception as e:
self.stdout.write(f'Error downloading park photo: {str(e)}')
self.stdout.write(
f"Error downloading park photo: {
str(e)}"
)
# Process rides and their photos
for ride_data in park_data['rides']:
for ride_data in park_data["rides"]:
try:
ride = Ride.objects.get(name=ride_data['name'], park=park)
ride = Ride.objects.get(name=ride_data["name"], park=park)
# Download ride photos
for idx, photo_url in enumerate(ride_data['photos'], 1):
for idx, photo_url in enumerate(ride_data["photos"], 1):
try:
# Download image
self.stdout.write(f'Downloading from URL: {photo_url}')
self.stdout.write(f"Downloading from URL: {photo_url}")
response = requests.get(photo_url, timeout=60)
if response.status_code == 200:
# Delete any existing photos for this ride
Photo.objects.filter(
content_type=ride_content_type,
object_id=ride.id
object_id=ride.id,
).delete()
# Create new photo record
photo = Photo(
content_type=ride_content_type,
object_id=ride.id,
is_primary=idx == 1
is_primary=idx == 1,
)
# Save image content
photo.image.save(
f"{ride.slug}_{idx}.jpg",
ContentFile(response.content),
save=False
save=False,
)
photo.save()
self.stdout.write(f'Downloaded photo for {ride.name}: {photo.image.name}')
self.stdout.write(f'Database record created with ID: {photo.id}')
self.stdout.write(
f"Downloaded photo for {
ride.name}: {
photo.image.name}"
)
self.stdout.write(
f"Database record created with ID: {
photo.id}"
)
else:
self.stdout.write(f'Error downloading image. Status code: {response.status_code}')
self.stdout.write(
f"Error downloading image. Status code: {
response.status_code}"
)
except Exception as e:
self.stdout.write(f'Error downloading ride photo: {str(e)}')
self.stdout.write(
f"Error downloading ride photo: {str(e)}"
)
except Ride.DoesNotExist:
self.stdout.write(f'Ride not found: {ride_data["name"]}')
self.stdout.write(
f'Ride not found: {
ride_data["name"]}'
)
except Park.DoesNotExist:
self.stdout.write(f'Park not found: {park_data["name"]}')
self.stdout.write('Finished downloading photos')
self.stdout.write("Finished downloading photos")

View File

@@ -1,58 +1,77 @@
import os
from django.core.management.base import BaseCommand
from media.models import Photo
from django.conf import settings
from django.db import transaction
class Command(BaseCommand):
help = 'Fix photo paths in database to match actual file locations'
help = "Fix photo paths in database to match actual file locations"
def handle(self, *args, **kwargs):
self.stdout.write('Fixing photo paths in database...')
self.stdout.write("Fixing photo paths in database...")
# Get all photos
photos = Photo.objects.all()
for photo in photos:
try:
with transaction.atomic():
# Get current file path
current_name = photo.image.name
# Remove any 'media/' prefix if it exists
if current_name.startswith('media/'):
current_name = current_name[6:] # Remove 'media/' prefix
parts = current_name.split('/')
if current_name.startswith("media/"):
# Remove 'media/' prefix
current_name = current_name[6:]
parts = current_name.split("/")
if len(parts) >= 2:
content_type = parts[0] # 'park' or 'ride'
identifier = parts[1] # e.g., 'alton-towers'
identifier = parts[1] # e.g., 'alton-towers'
# Look for files in the media directory
media_dir = os.path.join('media', content_type, identifier)
media_dir = os.path.join("media", content_type, identifier)
if os.path.exists(media_dir):
files = [f for f in os.listdir(media_dir)
if not f.startswith('.') and # Skip hidden files
not f.startswith('tmp') and # Skip temp files
os.path.isfile(os.path.join(media_dir, f))]
files = [
f
for f in os.listdir(media_dir)
if not f.startswith(".") # Skip hidden files
and not f.startswith("tmp") # Skip temp files
and os.path.isfile(os.path.join(media_dir, f))
]
if files:
# Get the first file and update the database record
file_path = os.path.join(content_type, identifier, files[0])
if os.path.exists(os.path.join('media', file_path)):
# Get the first file and update the database
# record
file_path = os.path.join(
content_type, identifier, files[0]
)
if os.path.exists(os.path.join("media", file_path)):
photo.image.name = file_path
photo.save()
self.stdout.write(f'Updated path for photo {photo.id} to {file_path}')
self.stdout.write(
f"Updated path for photo {
photo.id} to {file_path}"
)
else:
self.stdout.write(f'File not found for photo {photo.id}: {file_path}')
self.stdout.write(
f"File not found for photo {
photo.id}: {file_path}"
)
else:
self.stdout.write(f'No files found in directory for photo {photo.id}: {media_dir}')
self.stdout.write(
f"No files found in directory for photo {
photo.id}: {media_dir}"
)
else:
self.stdout.write(f'Directory not found for photo {photo.id}: {media_dir}')
self.stdout.write(
f"Directory not found for photo {
photo.id}: {media_dir}"
)
except Exception as e:
self.stdout.write(f'Error updating photo {photo.id}: {str(e)}')
self.stdout.write(f"Error updating photo {photo.id}: {str(e)}")
continue
self.stdout.write('Finished fixing photo paths')
self.stdout.write("Finished fixing photo paths")

View File

@@ -72,7 +72,10 @@ class Migration(migrations.Migration):
migrations.CreateModel(
name="PhotoEvent",
fields=[
("pgh_id", models.AutoField(primary_key=True, serialize=False)),
(
"pgh_id",
models.AutoField(primary_key=True, serialize=False),
),
("pgh_created_at", models.DateTimeField(auto_now_add=True)),
("pgh_label", models.TextField(help_text="The event label.")),
("id", models.BigIntegerField()),

View File

@@ -1,12 +1,9 @@
from typing import Any, Optional, Union, cast
from typing import Any, Optional, cast
from django.db import models
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.utils.text import slugify
from django.conf import settings
import os
from PIL import Image, ExifTags
from PIL.ExifTags import TAGS
from datetime import datetime
from .storage import MediaStorage
from rides.models import Ride
@@ -14,39 +11,42 @@ from django.utils import timezone
from core.history import TrackedModel
import pghistory
def photo_upload_path(instance: models.Model, filename: str) -> str:
"""Generate upload path for photos using normalized filenames"""
# Get the content type and object
photo = cast(Photo, instance)
content_type = photo.content_type.model
obj = photo.content_object
if obj is None:
raise ValueError("Content object cannot be None")
# Get object identifier (slug or id)
identifier = getattr(obj, 'slug', None)
identifier = getattr(obj, "slug", None)
if identifier is None:
identifier = obj.pk # Use pk instead of id as it's guaranteed to exist
# Create normalized filename - always use .jpg extension
base_filename = f"{identifier}.jpg"
# If it's a ride photo, store it under the park's directory
if content_type == 'ride':
if content_type == "ride":
ride = cast(Ride, obj)
return f"park/{ride.park.slug}/{identifier}/{base_filename}"
# For park photos, store directly in park directory
return f"park/{identifier}/{base_filename}"
@pghistory.track()
class Photo(TrackedModel):
"""Generic photo model that can be attached to any model"""
image = models.ImageField(
upload_to=photo_upload_path, # type: ignore[arg-type]
max_length=255,
storage=MediaStorage()
storage=MediaStorage(),
)
caption = models.CharField(max_length=255, blank=True)
alt_text = models.CharField(max_length=255, blank=True)
@@ -59,20 +59,20 @@ class Photo(TrackedModel):
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
related_name='uploaded_photos'
related_name="uploaded_photos",
)
# Generic foreign key fields
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
object_id = models.PositiveIntegerField()
content_object = GenericForeignKey('content_type', 'object_id')
content_object = GenericForeignKey("content_type", "object_id")
class Meta:
ordering = ['-is_primary', '-created_at']
ordering = ["-is_primary", "-created_at"]
indexes = [
models.Index(fields=['content_type', 'object_id']),
models.Index(fields=["content_type", "object_id"]),
]
def __str__(self) -> str:
return f"{self.content_type} - {self.content_object} - {self.caption or 'No caption'}"
@@ -84,15 +84,16 @@ class Photo(TrackedModel):
if exif:
# Find the DateTime tag ID
for tag_id in ExifTags.TAGS:
if ExifTags.TAGS[tag_id] == 'DateTimeOriginal':
if ExifTags.TAGS[tag_id] == "DateTimeOriginal":
if tag_id in exif:
# EXIF dates are typically in format: '2024:02:15 14:30:00'
# 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 datetime.strptime(date_str, "%Y:%m:%d %H:%M:%S")
return None
except Exception:
return 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:
@@ -101,14 +102,18 @@ class Photo(TrackedModel):
# Set default caption if not provided
if not self.caption and self.uploaded_by:
current_time = timezone.now()
self.caption = f"Uploaded by {self.uploaded_by.username} on {current_time.strftime('%B %d, %Y at %I:%M %p')}"
self.caption = f"Uploaded by {
self.uploaded_by.username} on {
current_time.strftime('%B %d, %Y at %I:%M %p')}"
# If this is marked as primary, unmark other primary photos
if self.is_primary:
Photo.objects.filter(
content_type=self.content_type,
object_id=self.object_id,
is_primary=True
).exclude(pk=self.pk).update(is_primary=False) # Use pk instead of id
is_primary=True,
).exclude(pk=self.pk).update(
is_primary=False
) # Use pk instead of id
super().save(*args, **kwargs)

View File

@@ -4,23 +4,23 @@ from django.core.files.base import File
from django.core.files.move import file_move_safe
from django.core.files.uploadedfile import UploadedFile, TemporaryUploadedFile
import os
import re
from typing import Optional, Any, Union
class MediaStorage(FileSystemStorage):
_instance = None
_counters = {}
def __init__(self, *args: Any, **kwargs: Any) -> None:
kwargs['location'] = settings.MEDIA_ROOT
kwargs['base_url'] = settings.MEDIA_URL
kwargs["location"] = settings.MEDIA_ROOT
kwargs["base_url"] = settings.MEDIA_URL
super().__init__(*args, **kwargs)
@classmethod
def reset_counters(cls):
"""Reset all counters - useful for testing"""
cls._counters = {}
def get_available_name(self, name: str, max_length: Optional[int] = None) -> str:
"""
Returns a filename that's free on the target storage system.
@@ -29,28 +29,28 @@ class MediaStorage(FileSystemStorage):
# Get the directory and filename
directory = os.path.dirname(name)
filename = os.path.basename(name)
# Create directory if it doesn't exist
full_dir = os.path.join(self.location, directory)
os.makedirs(full_dir, exist_ok=True)
# Split filename into root and extension
file_root, file_ext = os.path.splitext(filename)
# Extract base name without any existing numbers
base_root = file_root.rsplit('_', 1)[0]
base_root = file_root.rsplit("_", 1)[0]
# Use counter for this directory
dir_key = os.path.join(directory, base_root)
if dir_key not in self._counters:
self._counters[dir_key] = 0
self._counters[dir_key] += 1
counter = self._counters[dir_key]
new_name = f"{base_root}_{counter}{file_ext}"
return os.path.join(directory, new_name)
def _save(self, name: str, content: Union[File, UploadedFile]) -> str:
"""
Save the file and set proper permissions
@@ -58,25 +58,25 @@ class MediaStorage(FileSystemStorage):
# Get the full path where the file will be saved
full_path = self.path(name)
directory = os.path.dirname(full_path)
# Create the directory if it doesn't exist
os.makedirs(directory, exist_ok=True)
# Save the file using Django's file handling
if isinstance(content, TemporaryUploadedFile):
# This is a TemporaryUploadedFile
file_move_safe(content.temporary_file_path(), full_path)
else:
# This is an InMemoryUploadedFile or similar
with open(full_path, 'wb') as destination:
if hasattr(content, 'chunks'):
with open(full_path, "wb") as destination:
if hasattr(content, "chunks"):
for chunk in content.chunks():
destination.write(chunk)
else:
destination.write(content.read())
# Set proper permissions
os.chmod(full_path, 0o644)
os.chmod(directory, 0o755)
return name

View File

@@ -4,15 +4,18 @@ import json
register = template.Library()
@register.filter
def serialize_photos(photos):
"""Serialize photos queryset to JSON for AlpineJS"""
photo_data = []
for photo in photos:
photo_data.append({
'id': photo.id,
'url': photo.image.url,
'caption': photo.caption or '',
'is_primary': photo.is_primary
})
photo_data.append(
{
"id": photo.id,
"url": photo.image.url,
"caption": photo.caption or "",
"is_primary": photo.is_primary,
}
)
return json.dumps(photo_data, cls=DjangoJSONEncoder)

View File

@@ -4,11 +4,10 @@ from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from django.utils import timezone
from django.conf import settings
from django.test.utils import override_settings
from django.db import models
from datetime import datetime
from PIL import Image
import piexif # type: ignore
import piexif # type: ignore
import io
import shutil
import tempfile
@@ -23,18 +22,19 @@ from parks.models import Park, Company as Operator
User = get_user_model()
logger = logging.getLogger(__name__)
@override_settings(MEDIA_ROOT=tempfile.mkdtemp())
class PhotoModelTests(TestCase):
test_media_root: str
user: models.Model
park: Park
content_type: ContentType
@classmethod
def setUpClass(cls) -> None:
super().setUpClass()
cls.test_media_root = settings.MEDIA_ROOT
@classmethod
def tearDownClass(cls) -> None:
try:
@@ -48,7 +48,7 @@ class PhotoModelTests(TestCase):
self.park = self._create_test_park()
self.content_type = ContentType.objects.get_for_model(Park)
self._setup_test_directory()
def tearDown(self) -> None:
self._cleanup_test_directory()
Photo.objects.all().delete()
@@ -57,31 +57,26 @@ class PhotoModelTests(TestCase):
def _create_test_user(self) -> models.Model:
"""Create a test user for the tests"""
return User.objects.create_user(
username='testuser',
password='testpass123'
)
return User.objects.create_user(username="testuser", password="testpass123")
def _create_test_park(self) -> Park:
"""Create a test park for the tests"""
operator = Operator.objects.create(name='Test Operator')
operator = Operator.objects.create(name="Test Operator")
return Park.objects.create(
name='Test Park',
slug='test-park',
operator=operator
name="Test Park", slug="test-park", operator=operator
)
def _setup_test_directory(self) -> None:
"""Set up test directory and clean any existing test files"""
try:
# Clean up any existing test park directory
test_park_dir = os.path.join(settings.MEDIA_ROOT, 'park', 'test-park')
test_park_dir = os.path.join(settings.MEDIA_ROOT, "park", "test-park")
if os.path.exists(test_park_dir):
shutil.rmtree(test_park_dir, ignore_errors=True)
# Create necessary directories
os.makedirs(test_park_dir, exist_ok=True)
except Exception as e:
logger.warning(f"Failed to set up test directory: {e}")
raise
@@ -89,7 +84,7 @@ class PhotoModelTests(TestCase):
def _cleanup_test_directory(self) -> None:
"""Clean up test directories and files"""
try:
test_park_dir = os.path.join(settings.MEDIA_ROOT, 'park', 'test-park')
test_park_dir = os.path.join(settings.MEDIA_ROOT, "park", "test-park")
if os.path.exists(test_park_dir):
shutil.rmtree(test_park_dir, ignore_errors=True)
except Exception as e:
@@ -104,25 +99,29 @@ class PhotoModelTests(TestCase):
finally:
MediaStorage.reset_counters()
def create_test_image_with_exif(self, date_taken: Optional[datetime] = None, filename: str = 'test.jpg') -> SimpleUploadedFile:
def create_test_image_with_exif(
self, date_taken: Optional[datetime] = None, filename: str = "test.jpg"
) -> SimpleUploadedFile:
"""Helper method to create a test image with EXIF data"""
image = Image.new('RGB', (100, 100), color='red')
image = Image.new("RGB", (100, 100), color="red")
image_io = io.BytesIO()
# Save image first without EXIF
image.save(image_io, 'JPEG')
image.save(image_io, "JPEG")
image_io.seek(0)
if date_taken:
# Create EXIF data
exif_dict = {
"0th": {},
"Exif": {
piexif.ExifIFD.DateTimeOriginal: date_taken.strftime("%Y:%m:%d %H:%M:%S").encode()
}
piexif.ExifIFD.DateTimeOriginal: date_taken.strftime(
"%Y:%m:%d %H:%M:%S"
).encode()
},
}
exif_bytes = piexif.dump(exif_dict)
# Insert EXIF into image
image_with_exif = io.BytesIO()
piexif.insert(exif_bytes, image_io.getvalue(), image_with_exif)
@@ -130,24 +129,20 @@ class PhotoModelTests(TestCase):
image_data = image_with_exif.getvalue()
else:
image_data = image_io.getvalue()
return SimpleUploadedFile(
filename,
image_data,
content_type='image/jpeg'
)
return SimpleUploadedFile(filename, image_data, content_type="image/jpeg")
def test_filename_normalization(self) -> None:
"""Test that filenames are properly normalized"""
with self._reset_storage_state():
# Test with various problematic filenames
test_cases = [
('test with spaces.jpg', 'test-park_1.jpg'),
('TEST_UPPER.JPG', 'test-park_2.jpg'),
('special@#chars.jpeg', 'test-park_3.jpg'),
('no-extension', 'test-park_4.jpg'),
('multiple...dots.jpg', 'test-park_5.jpg'),
('très_açaí.jpg', 'test-park_6.jpg'), # Unicode characters
("test with spaces.jpg", "test-park_1.jpg"),
("TEST_UPPER.JPG", "test-park_2.jpg"),
("special@#chars.jpeg", "test-park_3.jpg"),
("no-extension", "test-park_4.jpg"),
("multiple...dots.jpg", "test-park_5.jpg"),
("très_açaí.jpg", "test-park_6.jpg"), # Unicode characters
]
for input_name, expected_suffix in test_cases:
@@ -155,20 +150,22 @@ class PhotoModelTests(TestCase):
image=self.create_test_image_with_exif(filename=input_name),
uploaded_by=self.user,
content_type=self.content_type,
object_id=self.park.pk
object_id=self.park.pk,
)
# Check that the filename follows the normalized pattern
self.assertTrue(
photo.image.name.endswith(expected_suffix),
f"Expected filename to end with {expected_suffix}, got {photo.image.name}"
f"Expected filename to end with {expected_suffix}, got {
photo.image.name}",
)
# Verify the path structure
expected_path = f"park/{self.park.slug}/"
self.assertTrue(
photo.image.name.startswith(expected_path),
f"Expected path to start with {expected_path}, got {photo.image.name}"
f"Expected path to start with {expected_path}, got {
photo.image.name}",
)
def test_sequential_filename_numbering(self) -> None:
@@ -180,32 +177,32 @@ class PhotoModelTests(TestCase):
image=self.create_test_image_with_exif(),
uploaded_by=self.user,
content_type=self.content_type,
object_id=self.park.pk
object_id=self.park.pk,
)
expected_name = f"park/{self.park.slug}/test-park_{i}.jpg"
self.assertEqual(
photo.image.name,
expected_name,
f"Expected {expected_name}, got {photo.image.name}"
f"Expected {expected_name}, got {photo.image.name}",
)
def test_exif_date_extraction(self) -> None:
"""Test EXIF date extraction from uploaded photos"""
test_date = datetime(2024, 1, 1, 12, 0, 0)
image_file = self.create_test_image_with_exif(test_date)
photo = Photo.objects.create(
image=image_file,
uploaded_by=self.user,
content_type=self.content_type,
object_id=self.park.pk
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")
test_date.strftime("%Y-%m-%d %H:%M:%S"),
)
else:
self.skipTest("EXIF data extraction not supported in test environment")
@@ -213,14 +210,14 @@ class PhotoModelTests(TestCase):
def test_photo_without_exif(self) -> None:
"""Test photo upload without EXIF data"""
image_file = self.create_test_image_with_exif()
photo = Photo.objects.create(
image=image_file,
uploaded_by=self.user,
content_type=self.content_type,
object_id=self.park.pk
object_id=self.park.pk,
)
self.assertIsNone(photo.date_taken)
def test_default_caption(self) -> None:
@@ -229,9 +226,9 @@ class PhotoModelTests(TestCase):
image=self.create_test_image_with_exif(),
uploaded_by=self.user,
content_type=self.content_type,
object_id=self.park.pk
object_id=self.park.pk,
)
expected_prefix = f"Uploaded by {cast(Any, self.user).username} on"
self.assertTrue(photo.caption.startswith(expected_prefix))
@@ -242,20 +239,20 @@ class PhotoModelTests(TestCase):
uploaded_by=self.user,
content_type=self.content_type,
object_id=self.park.pk,
is_primary=True
is_primary=True,
)
photo2 = Photo.objects.create(
image=self.create_test_image_with_exif(),
uploaded_by=self.user,
content_type=self.content_type,
object_id=self.park.pk,
is_primary=True
is_primary=True,
)
photo1.refresh_from_db()
photo2.refresh_from_db()
self.assertFalse(photo1.is_primary)
self.assertTrue(photo2.is_primary)
@@ -267,7 +264,7 @@ class PhotoModelTests(TestCase):
uploaded_by=self.user,
content_type=self.content_type,
object_id=self.park.pk,
date_taken=test_date
date_taken=test_date,
)
self.assertEqual(photo.date_taken, test_date)

View File

@@ -8,6 +8,14 @@ urlpatterns = [
path(
"upload/<int:photo_id>/", views.delete_photo, name="delete"
), # Updated to match frontend
path("upload/<int:photo_id>/primary/", views.set_primary_photo, name="set_primary"),
path("upload/<int:photo_id>/caption/", views.update_caption, name="update_caption"),
path(
"upload/<int:photo_id>/primary/",
views.set_primary_photo,
name="set_primary",
),
path(
"upload/<int:photo_id>/caption/",
views.update_caption,
name="update_caption",
),
]

View File

@@ -2,7 +2,6 @@ from django.http import JsonResponse
from django.views.decorators.http import require_http_methods
from django.contrib.auth.decorators import login_required
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import PermissionDenied
from django.shortcuts import get_object_or_404
import json
import logging
@@ -52,7 +51,8 @@ def upload_photo(request):
)
except ContentType.DoesNotExist:
return JsonResponse(
{"error": f"Invalid content type: {app_label}.{model}"}, status=400
{"error": f"Invalid content type: {app_label}.{model}"},
status=400,
)
# Get the object instance
@@ -61,7 +61,8 @@ def upload_photo(request):
except Exception as e:
return JsonResponse(
{
"error": f"Object not found: {app_label}.{model} with id {object_id}. Error: {str(e)}"
"error": f"Object not found: {app_label}.{model} with id {object_id}. Error: {
str(e)}"
},
status=404,
)
@@ -69,14 +70,20 @@ def upload_photo(request):
# Check if user has permission to add photos
if not request.user.has_perm("media.add_photo"):
logger.warning(
f"User {request.user} attempted to upload photo without permission"
f"User {
request.user} attempted to upload photo without permission"
)
return JsonResponse(
{"error": "You do not have permission to upload photos"}, status=403
{"error": "You do not have permission to upload photos"},
status=403,
)
# Determine if the photo should be auto-approved
is_approved = request.user.is_superuser or request.user.is_staff or request.user.groups.filter(name='Moderators').exists()
is_approved = (
request.user.is_superuser
or request.user.is_staff
or request.user.groups.filter(name="Moderators").exists()
)
# Create the photo
photo = Photo.objects.create(
@@ -87,7 +94,8 @@ def upload_photo(request):
is_primary=not Photo.objects.filter(
content_type=content_type, object_id=obj.pk
).exists(),
is_approved=is_approved # Auto-approve if the user is a moderator, admin, or superuser
is_approved=is_approved,
# Auto-approve if the user is a moderator, admin, or superuser
)
return JsonResponse(
@@ -118,7 +126,8 @@ def set_primary_photo(request, photo_id):
# Check if user has permission to edit photos
if not request.user.has_perm("media.change_photo"):
return JsonResponse(
{"error": "You do not have permission to edit photos"}, status=403
{"error": "You do not have permission to edit photos"},
status=403,
)
# Set this photo as primary
@@ -142,7 +151,8 @@ def update_caption(request, photo_id):
# Check if user has permission to edit photos
if not request.user.has_perm("media.change_photo"):
return JsonResponse(
{"error": "You do not have permission to edit photos"}, status=403
{"error": "You do not have permission to edit photos"},
status=403,
)
# Update caption
@@ -167,7 +177,8 @@ def delete_photo(request, photo_id):
# Check if user has permission to delete photos
if not request.user.has_perm("media.delete_photo"):
return JsonResponse(
{"error": "You do not have permission to delete photos"}, status=403
{"error": "You do not have permission to delete photos"},
status=403,
)
photo.delete()