Refactor code structure and remove redundant changes

This commit is contained in:
pacnpal
2025-08-26 13:19:04 -04:00
parent bf7e0c0f40
commit 831be6a2ee
151 changed files with 16260 additions and 9137 deletions

View File

@@ -1,28 +0,0 @@
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",)
def thumbnail_preview(self, obj):
if obj.image:
return format_html(
'<img src="{}" style="max-height: 50px; max-width: 100px;" />',
obj.image.url,
)
return "No image"
thumbnail_preview.short_description = "Thumbnail"

View File

@@ -3,26 +3,46 @@ from django.db.models.signals import post_migrate
def create_photo_permissions(sender, **kwargs):
"""Create custom permissions for photos"""
"""Create custom permissions for domain-specific photo models"""
from django.contrib.auth.models import Permission
from django.contrib.contenttypes.models import ContentType
from apps.media.models import Photo
from apps.parks.models import ParkPhoto
from apps.rides.models import RidePhoto
content_type = ContentType.objects.get_for_model(Photo)
# Create permissions for ParkPhoto
park_photo_content_type = ContentType.objects.get_for_model(ParkPhoto)
Permission.objects.get_or_create(
codename="add_photo",
name="Can add photo",
content_type=content_type,
codename="add_parkphoto",
name="Can add park photo",
content_type=park_photo_content_type,
)
Permission.objects.get_or_create(
codename="change_photo",
name="Can change photo",
content_type=content_type,
codename="change_parkphoto",
name="Can change park photo",
content_type=park_photo_content_type,
)
Permission.objects.get_or_create(
codename="delete_photo",
name="Can delete photo",
content_type=content_type,
codename="delete_parkphoto",
name="Can delete park photo",
content_type=park_photo_content_type,
)
# Create permissions for RidePhoto
ride_photo_content_type = ContentType.objects.get_for_model(RidePhoto)
Permission.objects.get_or_create(
codename="add_ridephoto",
name="Can add ride photo",
content_type=ride_photo_content_type,
)
Permission.objects.get_or_create(
codename="change_ridephoto",
name="Can change ride photo",
content_type=ride_photo_content_type,
)
Permission.objects.get_or_create(
codename="delete_ridephoto",
name="Can delete ride photo",
content_type=ride_photo_content_type,
)

View File

@@ -1,9 +1,7 @@
import requests
from django.core.management.base import BaseCommand
from apps.media.models import Photo
from apps.parks.models import Park
from apps.rides.models import Ride
from django.contrib.contenttypes.models import ContentType
from apps.parks.models import Park, ParkPhoto
from apps.rides.models import Ride, RidePhoto
import json
from django.core.files.base import ContentFile
@@ -18,9 +16,6 @@ class Command(BaseCommand):
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"]:
try:
@@ -34,15 +29,11 @@ class Command(BaseCommand):
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,
).delete()
ParkPhoto.objects.filter(park=park).delete()
# Create new photo record
photo = Photo(
content_type=park_content_type,
object_id=park.id,
photo = ParkPhoto(
park=park,
is_primary=idx == 1,
)
@@ -87,15 +78,11 @@ class Command(BaseCommand):
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,
).delete()
RidePhoto.objects.filter(ride=ride).delete()
# Create new photo record
photo = Photo(
content_type=ride_content_type,
object_id=ride.id,
photo = RidePhoto(
ride=ride,
is_primary=idx == 1,
)

View File

@@ -1,6 +1,7 @@
import os
from django.core.management.base import BaseCommand
from apps.media.models import Photo
from apps.parks.models import ParkPhoto
from apps.rides.models import RidePhoto
from django.db import transaction
@@ -11,9 +12,11 @@ class Command(BaseCommand):
self.stdout.write("Fixing photo paths in database...")
# Get all photos
photos = Photo.objects.all()
park_photos = ParkPhoto.objects.all()
ride_photos = RidePhoto.objects.all()
for photo in photos:
# Process park photos
for photo in park_photos:
try:
with transaction.atomic():
# Get current file path
@@ -27,8 +30,8 @@ class Command(BaseCommand):
parts = current_name.split("/")
if len(parts) >= 2:
content_type = parts[0] # 'park' or 'ride'
identifier = parts[1] # e.g., 'alton-towers'
content_type = "park"
identifier = photo.park.slug
# Look for files in the media directory
media_dir = os.path.join("media", content_type, identifier)
@@ -51,27 +54,89 @@ class Command(BaseCommand):
photo.image.name = file_path
photo.save()
self.stdout.write(
f"Updated path for photo {
f"Updated path for park photo {
photo.id} to {file_path}"
)
else:
self.stdout.write(
f"File not found for photo {
f"File not found for park photo {
photo.id}: {file_path}"
)
else:
self.stdout.write(
f"No files found in directory for photo {
f"No files found in directory for park photo {
photo.id}: {media_dir}"
)
else:
self.stdout.write(
f"Directory not found for photo {
f"Directory not found for park 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 park photo {photo.id}: {str(e)}")
continue
# Process ride photos
for photo in ride_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/"):
# Remove 'media/' prefix
current_name = current_name[6:]
parts = current_name.split("/")
if len(parts) >= 2:
content_type = "ride"
identifier = photo.ride.slug
# Look for files in the media directory
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(".") # 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)):
photo.image.name = file_path
photo.save()
self.stdout.write(
f"Updated path for ride photo {
photo.id} to {file_path}"
)
else:
self.stdout.write(
f"File not found for ride photo {
photo.id}: {file_path}"
)
else:
self.stdout.write(
f"No files found in directory for ride photo {
photo.id}: {media_dir}"
)
else:
self.stdout.write(
f"Directory not found for ride photo {
photo.id}: {media_dir}"
)
except Exception as e:
self.stdout.write(f"Error updating ride photo {photo.id}: {str(e)}")
continue
self.stdout.write("Finished fixing photo paths")

View File

@@ -1,6 +1,7 @@
import os
from django.core.management.base import BaseCommand
from apps.media.models import Photo
from apps.parks.models import ParkPhoto
from apps.rides.models import RidePhoto
from django.conf import settings
import shutil
@@ -12,12 +13,93 @@ class Command(BaseCommand):
self.stdout.write("Moving photo files to normalized locations...")
# Get all photos
photos = Photo.objects.all()
park_photos = ParkPhoto.objects.all()
ride_photos = RidePhoto.objects.all()
# Track processed files to clean up later
processed_files = set()
for photo in photos:
# Process park photos
for photo in park_photos:
try:
# Get current file path
current_name = photo.image.name
current_path = os.path.join(settings.MEDIA_ROOT, current_name)
# Try to find the actual file
if not os.path.exists(current_path):
# Check if file exists in the old location structure
parts = current_name.split("/")
if len(parts) >= 2:
content_type = "park"
identifier = photo.park.slug
# Look for any files in that directory
old_dir = os.path.join(
settings.MEDIA_ROOT, content_type, identifier
)
if os.path.exists(old_dir):
files = [
f
for f in os.listdir(old_dir)
if not f.startswith(".") # Skip hidden files
and not f.startswith("tmp") # Skip temp files
and os.path.isfile(os.path.join(old_dir, f))
]
if files:
current_path = os.path.join(old_dir, files[0])
# Skip if file still not found
if not os.path.exists(current_path):
self.stdout.write(f"Skipping {current_name} - file not found")
continue
# Get content type and object
content_type_model = "park"
obj = photo.park
identifier = getattr(obj, "slug", obj.id)
# Get photo number
photo_number = ParkPhoto.objects.filter(
park=photo.park,
created_at__lte=photo.created_at,
).count()
# Create new filename
_, ext = os.path.splitext(current_path)
if not ext:
ext = ".jpg"
ext = ext.lower()
new_filename = f"{identifier}_{photo_number}{ext}"
# Create new path
new_relative_path = f"{content_type_model}/{identifier}/{new_filename}"
new_full_path = os.path.join(settings.MEDIA_ROOT, new_relative_path)
# Create directory if it doesn't exist
os.makedirs(os.path.dirname(new_full_path), exist_ok=True)
# Move the file
if current_path != new_full_path:
shutil.copy2(
current_path, new_full_path
) # Use copy2 to preserve metadata
processed_files.add(current_path)
else:
processed_files.add(current_path)
# Update database
photo.image.name = new_relative_path
photo.save()
self.stdout.write(f"Moved {current_name} to {new_relative_path}")
except Exception as e:
self.stdout.write(f"Error moving park photo {photo.id}: {str(e)}")
continue
# Process ride photos
for photo in ride_photos:
try:
# Get current file path
current_name = photo.image.name
@@ -52,14 +134,13 @@ class Command(BaseCommand):
continue
# Get content type and object
content_type_model = photo.content_type.model
obj = photo.content_object
content_type_model = "ride"
obj = photo.ride
identifier = getattr(obj, "slug", obj.id)
# Get photo number
photo_number = Photo.objects.filter(
content_type=photo.content_type,
object_id=photo.object_id,
photo_number = RidePhoto.objects.filter(
ride=photo.ride,
created_at__lte=photo.created_at,
).count()
@@ -93,7 +174,7 @@ class Command(BaseCommand):
self.stdout.write(f"Moved {current_name} to {new_relative_path}")
except Exception as e:
self.stdout.write(f"Error moving photo {photo.id}: {str(e)}")
self.stdout.write(f"Error moving ride photo {photo.id}: {str(e)}")
continue
# Clean up old files

View File

@@ -1,21 +0,0 @@
from django import template
from django.core.serializers.json import DjangoJSONEncoder
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,
}
)
return json.dumps(photo_data, cls=DjangoJSONEncoder)

View File

@@ -1,120 +0,0 @@
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.conf import settings
from PIL import Image, ExifTags
from datetime import datetime
from .storage import MediaStorage
from apps.rides.models import Ride
from django.utils import timezone
from apps.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)
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":
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(),
)
caption = models.CharField(max_length=255, blank=True)
alt_text = models.CharField(max_length=255, blank=True)
is_primary = models.BooleanField(default=False)
is_approved = models.BooleanField(default=False) # New field for approval status
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
date_taken = models.DateTimeField(null=True, blank=True)
uploaded_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
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")
class Meta:
app_label = "media"
ordering = ["-is_primary", "-created_at"]
indexes = [
models.Index(fields=["content_type", "object_id"]),
]
def __str__(self) -> str:
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:
# 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
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')}"
# 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
super().save(*args, **kwargs)

View File

@@ -1,82 +0,0 @@
from django.core.files.storage import FileSystemStorage
from django.conf import settings
from django.core.files.base import File
from django.core.files.move import file_move_safe
from django.core.files.uploadedfile import UploadedFile, TemporaryUploadedFile
import os
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
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.
Ensures proper normalization and uniqueness.
"""
# 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]
# 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
"""
# Get the full path where the file will be saved
full_path = self.path(name)
directory = os.path.dirname(full_path)
# Create the directory if it doesn't exist
os.makedirs(directory, exist_ok=True)
# Save the file using Django's file handling
if isinstance(content, TemporaryUploadedFile):
# This is a TemporaryUploadedFile
file_move_safe(content.temporary_file_path(), full_path)
else:
# This is an InMemoryUploadedFile or similar
with open(full_path, "wb") as destination:
if hasattr(content, "chunks"):
for chunk in content.chunks():
destination.write(chunk)
else:
destination.write(content.read())
# Set proper permissions
os.chmod(full_path, 0o644)
os.chmod(directory, 0o755)
return name

View File

@@ -1,270 +0,0 @@
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 django.conf import settings
from django.db import models
from datetime import datetime
from PIL import Image
import piexif # type: ignore
import io
import shutil
import tempfile
import os
import logging
from typing import Optional, Any, Generator, cast
from contextlib import contextmanager
from .models import Photo
from .storage import MediaStorage
from apps.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:
shutil.rmtree(cls.test_media_root, ignore_errors=True)
except Exception as e:
logger.warning(f"Failed to clean up test media directory: {e}")
super().tearDownClass()
def setUp(self) -> None:
self.user = self._create_test_user()
self.park = self._create_test_park()
self.content_type = ContentType.objects.get_for_model(Park)
self._setup_test_directory()
def tearDown(self) -> None:
self._cleanup_test_directory()
Photo.objects.all().delete()
with self._reset_storage_state():
pass
def _create_test_user(self) -> models.Model:
"""Create a test user for the tests"""
return User.objects.create_user(username="testuser", password="testpass123")
def _create_test_park(self) -> Park:
"""Create a test park for the tests"""
operator = Operator.objects.create(name="Test Operator")
return Park.objects.create(
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")
if os.path.exists(test_park_dir):
shutil.rmtree(test_park_dir, ignore_errors=True)
# Create necessary directories
os.makedirs(test_park_dir, exist_ok=True)
except Exception as e:
logger.warning(f"Failed to set up test directory: {e}")
raise
def _cleanup_test_directory(self) -> None:
"""Clean up test directories and files"""
try:
test_park_dir = os.path.join(settings.MEDIA_ROOT, "park", "test-park")
if os.path.exists(test_park_dir):
shutil.rmtree(test_park_dir, ignore_errors=True)
except Exception as e:
logger.warning(f"Failed to clean up test directory: {e}")
@contextmanager
def _reset_storage_state(self) -> Generator[None, None, None]:
"""Safely reset storage state"""
try:
MediaStorage.reset_counters()
yield
finally:
MediaStorage.reset_counters()
def create_test_image_with_exif(
self, date_taken: Optional[datetime] = None, filename: str = "test.jpg"
) -> SimpleUploadedFile:
"""Helper method to create a test image with EXIF data"""
image = Image.new("RGB", (100, 100), color="red")
image_io = io.BytesIO()
# Save image first without EXIF
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()
},
}
exif_bytes = piexif.dump(exif_dict)
# Insert EXIF into image
image_with_exif = io.BytesIO()
piexif.insert(exif_bytes, image_io.getvalue(), image_with_exif)
image_with_exif.seek(0)
image_data = image_with_exif.getvalue()
else:
image_data = image_io.getvalue()
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
]
for input_name, expected_suffix in test_cases:
photo = Photo.objects.create(
image=self.create_test_image_with_exif(filename=input_name),
uploaded_by=self.user,
content_type=self.content_type,
object_id=self.park.pk,
)
# Check that the filename follows the normalized pattern
self.assertTrue(
photo.image.name.endswith(expected_suffix),
f"Expected filename to end with {expected_suffix}, got {
photo.image.name}",
)
# Verify the path structure
expected_path = f"park/{self.park.slug}/"
self.assertTrue(
photo.image.name.startswith(expected_path),
f"Expected path to start with {expected_path}, got {
photo.image.name}",
)
def test_sequential_filename_numbering(self) -> None:
"""Test that sequential files get proper numbering"""
with self._reset_storage_state():
# Create multiple photos and verify numbering
for i in range(1, 4):
photo = Photo.objects.create(
image=self.create_test_image_with_exif(),
uploaded_by=self.user,
content_type=self.content_type,
object_id=self.park.pk,
)
expected_name = f"park/{self.park.slug}/test-park_{i}.jpg"
self.assertEqual(
photo.image.name,
expected_name,
f"Expected {expected_name}, got {photo.image.name}",
)
def test_exif_date_extraction(self) -> None:
"""Test EXIF date extraction from uploaded photos"""
test_date = datetime(2024, 1, 1, 12, 0, 0)
image_file = self.create_test_image_with_exif(test_date)
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) -> 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,
)
self.assertIsNone(photo.date_taken)
def test_default_caption(self) -> None:
"""Test default caption generation"""
photo = Photo.objects.create(
image=self.create_test_image_with_exif(),
uploaded_by=self.user,
content_type=self.content_type,
object_id=self.park.pk,
)
expected_prefix = f"Uploaded by {cast(Any, self.user).username} on"
self.assertTrue(photo.caption.startswith(expected_prefix))
def test_primary_photo_toggle(self) -> None:
"""Test primary photo functionality"""
photo1 = 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,
)
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,
)
photo1.refresh_from_db()
photo2.refresh_from_db()
self.assertFalse(photo1.is_primary)
self.assertTrue(photo2.is_primary)
def test_date_taken_field(self) -> None:
"""Test date_taken field functionality"""
test_date = timezone.now()
photo = Photo.objects.create(
image=self.create_test_image_with_exif(),
uploaded_by=self.user,
content_type=self.content_type,
object_id=self.park.pk,
date_taken=test_date,
)
self.assertEqual(photo.date_taken, test_date)

View File

@@ -1,21 +0,0 @@
from django.urls import path
from . import views
app_name = "photos"
urlpatterns = [
path("upload/", views.upload_photo, name="upload"),
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",
),
]

View File

@@ -1,189 +0,0 @@
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.shortcuts import get_object_or_404
import json
import logging
from .models import Photo
logger = logging.getLogger(__name__)
@login_required
@require_http_methods(["POST"])
def upload_photo(request):
"""Handle photo upload for any model"""
try:
# Get app label, model, and object ID
app_label = request.POST.get("app_label")
model = request.POST.get("model")
object_id = request.POST.get("object_id")
# Log received data
logger.debug(
f"Received upload request - app_label: {app_label}, model: {model}, object_id: {object_id}"
)
logger.debug(f"Files in request: {request.FILES}")
# Validate required fields
missing_fields = []
if not app_label:
missing_fields.append("app_label")
if not model:
missing_fields.append("model")
if not object_id:
missing_fields.append("object_id")
if "image" not in request.FILES:
missing_fields.append("image")
if missing_fields:
return JsonResponse(
{"error": f'Missing required fields: {", ".join(missing_fields)}'},
status=400,
)
# Get content type
try:
content_type = ContentType.objects.get(
app_label=app_label.lower(), model=model.lower()
)
except ContentType.DoesNotExist:
return JsonResponse(
{"error": f"Invalid content type: {app_label}.{model}"},
status=400,
)
# Get the object instance
try:
obj = content_type.get_object_for_this_type(pk=object_id)
except Exception as e:
return JsonResponse(
{
"error": f"Object not found: {app_label}.{model} with id {object_id}. Error: {
str(e)}"
},
status=404,
)
# 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"
)
return JsonResponse(
{"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()
)
# Create the photo
photo = Photo.objects.create(
image=request.FILES["image"],
content_type=content_type,
object_id=obj.pk,
uploaded_by=request.user, # Add the user who uploaded the photo
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
)
return JsonResponse(
{
"id": photo.pk,
"url": photo.image.url,
"caption": photo.caption,
"is_primary": photo.is_primary,
"is_approved": photo.is_approved,
}
)
except Exception as e:
logger.error(f"Error in upload_photo: {str(e)}", exc_info=True)
return JsonResponse(
{"error": f"An error occurred while uploading the photo: {str(e)}"},
status=400,
)
@login_required
@require_http_methods(["POST"])
def set_primary_photo(request, photo_id):
"""Set a photo as primary"""
try:
photo = get_object_or_404(Photo, pk=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,
)
# Set this photo as primary
photo.is_primary = True
photo.save() # This will automatically unset other primary photos
return JsonResponse({"status": "success"})
except Exception as e:
logger.error(f"Error in set_primary_photo: {str(e)}", exc_info=True)
return JsonResponse({"error": str(e)}, status=400)
@login_required
@require_http_methods(["POST"])
def update_caption(request, photo_id):
"""Update a photo's caption"""
try:
photo = get_object_or_404(Photo, pk=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,
)
# Update caption
data = json.loads(request.body)
photo.caption = data.get("caption", "")
photo.save()
return JsonResponse({"id": photo.pk, "caption": photo.caption})
except Exception as e:
logger.error(f"Error in update_caption: {str(e)}", exc_info=True)
return JsonResponse({"error": str(e)}, status=400)
@login_required
@require_http_methods(["DELETE"])
def delete_photo(request, photo_id):
"""Delete a photo"""
try:
photo = get_object_or_404(Photo, pk=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,
)
photo.delete()
return JsonResponse({"status": "success"})
except Exception as e:
logger.error(f"Error in delete_photo: {str(e)}", exc_info=True)
return JsonResponse({"error": str(e)}, status=400)