mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-23 23:11:09 -05:00
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:
@@ -1 +1 @@
|
||||
default_app_config = 'media.apps.MediaConfig'
|
||||
default_app_config = "media.apps.MediaConfig"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
119
media/tests.py
119
media/tests.py
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user