mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 10:11:09 -05:00
feat: complete monorepo structure with frontend and shared resources
- Add complete backend/ directory with full Django application - Add frontend/ directory with Vite + TypeScript setup ready for Next.js - Add comprehensive shared/ directory with: - Complete documentation and memory-bank archives - Media files and avatars (letters, park/ride images) - Deployment scripts and automation tools - Shared types and utilities - Add architecture/ directory with migration guides - Configure pnpm workspace for monorepo development - Update .gitignore to exclude .django_tailwind_cli/ build artifacts - Preserve all historical documentation in shared/docs/memory-bank/ - Set up proper structure for full-stack development with shared resources
This commit is contained in:
179
backend/apps/media/0001_initial.py
Normal file
179
backend/apps/media/0001_initial.py
Normal file
@@ -0,0 +1,179 @@
|
||||
# Generated by Django 5.1.4 on 2025-08-13 21:35
|
||||
|
||||
import django.db.models.deletion
|
||||
import apps.media.models
|
||||
import apps.media.storage
|
||||
import pgtrigger.compiler
|
||||
import pgtrigger.migrations
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
("contenttypes", "0002_remove_content_type_name"),
|
||||
("pghistory", "0006_delete_aggregateevent"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="Photo",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"image",
|
||||
models.ImageField(
|
||||
max_length=255,
|
||||
storage=apps.media.storage.MediaStorage(),
|
||||
upload_to=apps.media.models.photo_upload_path,
|
||||
),
|
||||
),
|
||||
("caption", models.CharField(blank=True, max_length=255)),
|
||||
("alt_text", models.CharField(blank=True, max_length=255)),
|
||||
("is_primary", models.BooleanField(default=False)),
|
||||
("is_approved", models.BooleanField(default=False)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
("date_taken", models.DateTimeField(blank=True, null=True)),
|
||||
("object_id", models.PositiveIntegerField()),
|
||||
(
|
||||
"content_type",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="contenttypes.contenttype",
|
||||
),
|
||||
),
|
||||
(
|
||||
"uploaded_by",
|
||||
models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="uploaded_photos",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["-is_primary", "-created_at"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="PhotoEvent",
|
||||
fields=[
|
||||
(
|
||||
"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()),
|
||||
(
|
||||
"image",
|
||||
models.ImageField(
|
||||
max_length=255,
|
||||
storage=apps.media.storage.MediaStorage(),
|
||||
upload_to=apps.media.models.photo_upload_path,
|
||||
),
|
||||
),
|
||||
("caption", models.CharField(blank=True, max_length=255)),
|
||||
("alt_text", models.CharField(blank=True, max_length=255)),
|
||||
("is_primary", models.BooleanField(default=False)),
|
||||
("is_approved", models.BooleanField(default=False)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
("date_taken", models.DateTimeField(blank=True, null=True)),
|
||||
("object_id", models.PositiveIntegerField()),
|
||||
(
|
||||
"content_type",
|
||||
models.ForeignKey(
|
||||
db_constraint=False,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
related_query_name="+",
|
||||
to="contenttypes.contenttype",
|
||||
),
|
||||
),
|
||||
(
|
||||
"pgh_context",
|
||||
models.ForeignKey(
|
||||
db_constraint=False,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
to="pghistory.context",
|
||||
),
|
||||
),
|
||||
(
|
||||
"pgh_obj",
|
||||
models.ForeignKey(
|
||||
db_constraint=False,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="events",
|
||||
to="media.photo",
|
||||
),
|
||||
),
|
||||
(
|
||||
"uploaded_by",
|
||||
models.ForeignKey(
|
||||
db_constraint=False,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
related_query_name="+",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="photo",
|
||||
index=models.Index(
|
||||
fields=["content_type", "object_id"],
|
||||
name="media_photo_content_0187f5_idx",
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="photo",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="insert_insert",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
func='INSERT INTO "media_photoevent" ("alt_text", "caption", "content_type_id", "created_at", "date_taken", "id", "image", "is_approved", "is_primary", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "updated_at", "uploaded_by_id") VALUES (NEW."alt_text", NEW."caption", NEW."content_type_id", NEW."created_at", NEW."date_taken", NEW."id", NEW."image", NEW."is_approved", NEW."is_primary", NEW."object_id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."updated_at", NEW."uploaded_by_id"); RETURN NULL;',
|
||||
hash="[AWS-SECRET-REMOVED]",
|
||||
operation="INSERT",
|
||||
pgid="pgtrigger_insert_insert_e1ca0",
|
||||
table="media_photo",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="photo",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="update_update",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
|
||||
func='INSERT INTO "media_photoevent" ("alt_text", "caption", "content_type_id", "created_at", "date_taken", "id", "image", "is_approved", "is_primary", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "updated_at", "uploaded_by_id") VALUES (NEW."alt_text", NEW."caption", NEW."content_type_id", NEW."created_at", NEW."date_taken", NEW."id", NEW."image", NEW."is_approved", NEW."is_primary", NEW."object_id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."updated_at", NEW."uploaded_by_id"); RETURN NULL;',
|
||||
hash="[AWS-SECRET-REMOVED]",
|
||||
operation="UPDATE",
|
||||
pgid="pgtrigger_update_update_6ff7d",
|
||||
table="media_photo",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
0
backend/apps/media/__init__.py
Normal file
0
backend/apps/media/__init__.py
Normal file
28
backend/apps/media/admin.py
Normal file
28
backend/apps/media/admin.py
Normal file
@@ -0,0 +1,28 @@
|
||||
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"
|
||||
34
backend/apps/media/apps.py
Normal file
34
backend/apps/media/apps.py
Normal file
@@ -0,0 +1,34 @@
|
||||
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
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from apps.media.models import Photo
|
||||
|
||||
content_type = ContentType.objects.get_for_model(Photo)
|
||||
Permission.objects.get_or_create(
|
||||
codename="add_photo",
|
||||
name="Can add photo",
|
||||
content_type=content_type,
|
||||
)
|
||||
Permission.objects.get_or_create(
|
||||
codename="change_photo",
|
||||
name="Can change photo",
|
||||
content_type=content_type,
|
||||
)
|
||||
Permission.objects.get_or_create(
|
||||
codename="delete_photo",
|
||||
name="Can delete photo",
|
||||
content_type=content_type,
|
||||
)
|
||||
|
||||
|
||||
class MediaConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.media"
|
||||
|
||||
def ready(self):
|
||||
post_migrate.connect(create_photo_permissions, sender=self)
|
||||
139
backend/apps/media/commands/download_photos.py
Normal file
139
backend/apps/media/commands/download_photos.py
Normal file
@@ -0,0 +1,139 @@
|
||||
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
|
||||
import json
|
||||
from django.core.files.base import ContentFile
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Download photos from seed data URLs"
|
||||
|
||||
def handle(self, *args, **kwargs):
|
||||
self.stdout.write("Downloading photos from seed data...")
|
||||
|
||||
# Read seed data
|
||||
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:
|
||||
park = Park.objects.get(name=park_data["name"])
|
||||
|
||||
# Download park photos
|
||||
for idx, photo_url in enumerate(park_data["photos"], 1):
|
||||
try:
|
||||
# Download image
|
||||
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,
|
||||
).delete()
|
||||
|
||||
# Create new photo record
|
||||
photo = Photo(
|
||||
content_type=park_content_type,
|
||||
object_id=park.id,
|
||||
is_primary=idx == 1,
|
||||
)
|
||||
|
||||
# Save image content
|
||||
photo.image.save(
|
||||
f"{park.slug}_{idx}.jpg",
|
||||
ContentFile(response.content),
|
||||
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}"
|
||||
)
|
||||
else:
|
||||
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)}"
|
||||
)
|
||||
|
||||
# Process rides and their photos
|
||||
for ride_data in park_data["rides"]:
|
||||
try:
|
||||
ride = Ride.objects.get(name=ride_data["name"], park=park)
|
||||
|
||||
# Download ride photos
|
||||
for idx, photo_url in enumerate(ride_data["photos"], 1):
|
||||
try:
|
||||
# Download image
|
||||
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,
|
||||
).delete()
|
||||
|
||||
# Create new photo record
|
||||
photo = Photo(
|
||||
content_type=ride_content_type,
|
||||
object_id=ride.id,
|
||||
is_primary=idx == 1,
|
||||
)
|
||||
|
||||
# Save image content
|
||||
photo.image.save(
|
||||
f"{ride.slug}_{idx}.jpg",
|
||||
ContentFile(response.content),
|
||||
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}"
|
||||
)
|
||||
else:
|
||||
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)}"
|
||||
)
|
||||
|
||||
except Ride.DoesNotExist:
|
||||
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")
|
||||
77
backend/apps/media/commands/fix_photo_paths.py
Normal file
77
backend/apps/media/commands/fix_photo_paths.py
Normal file
@@ -0,0 +1,77 @@
|
||||
import os
|
||||
from django.core.management.base import BaseCommand
|
||||
from apps.media.models import Photo
|
||||
from django.db import transaction
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Fix photo paths in database to match actual file locations"
|
||||
|
||||
def handle(self, *args, **kwargs):
|
||||
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/"):
|
||||
# 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'
|
||||
|
||||
# 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 photo {
|
||||
photo.id} to {file_path}"
|
||||
)
|
||||
else:
|
||||
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}"
|
||||
)
|
||||
else:
|
||||
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)}")
|
||||
continue
|
||||
|
||||
self.stdout.write("Finished fixing photo paths")
|
||||
116
backend/apps/media/commands/move_photos.py
Normal file
116
backend/apps/media/commands/move_photos.py
Normal file
@@ -0,0 +1,116 @@
|
||||
import os
|
||||
from django.core.management.base import BaseCommand
|
||||
from apps.media.models import Photo
|
||||
from django.conf import settings
|
||||
import shutil
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Move photo files to their normalized locations"
|
||||
|
||||
def handle(self, *args, **kwargs):
|
||||
self.stdout.write("Moving photo files to normalized locations...")
|
||||
|
||||
# Get all photos
|
||||
photos = Photo.objects.all()
|
||||
|
||||
# Track processed files to clean up later
|
||||
processed_files = set()
|
||||
|
||||
for photo in 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 = parts[0] # 'park' or 'ride'
|
||||
identifier = parts[1] # e.g., 'alton-towers'
|
||||
|
||||
# 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 = photo.content_type.model
|
||||
obj = photo.content_object
|
||||
identifier = getattr(obj, "slug", obj.id)
|
||||
|
||||
# Get photo number
|
||||
photo_number = Photo.objects.filter(
|
||||
content_type=photo.content_type,
|
||||
object_id=photo.object_id,
|
||||
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 photo {photo.id}: {str(e)}")
|
||||
continue
|
||||
|
||||
# Clean up old files
|
||||
self.stdout.write("Cleaning up old files...")
|
||||
for content_type in ["park", "ride"]:
|
||||
base_dir = os.path.join(settings.MEDIA_ROOT, content_type)
|
||||
if os.path.exists(base_dir):
|
||||
for root, dirs, files in os.walk(base_dir):
|
||||
for file in files:
|
||||
file_path = os.path.join(root, file)
|
||||
if file_path not in processed_files:
|
||||
try:
|
||||
os.remove(file_path)
|
||||
self.stdout.write(f"Removed old file: {file_path}")
|
||||
except Exception as e:
|
||||
self.stdout.write(
|
||||
f"Error removing {file_path}: {str(e)}"
|
||||
)
|
||||
|
||||
self.stdout.write("Finished moving photo files and cleaning up")
|
||||
21
backend/apps/media/json_filters.py
Normal file
21
backend/apps/media/json_filters.py
Normal file
@@ -0,0 +1,21 @@
|
||||
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)
|
||||
120
backend/apps/media/models.py
Normal file
120
backend/apps/media/models.py
Normal file
@@ -0,0 +1,120 @@
|
||||
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)
|
||||
82
backend/apps/media/storage.py
Normal file
82
backend/apps/media/storage.py
Normal file
@@ -0,0 +1,82 @@
|
||||
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
|
||||
270
backend/apps/media/tests.py
Normal file
270
backend/apps/media/tests.py
Normal file
@@ -0,0 +1,270 @@
|
||||
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)
|
||||
21
backend/apps/media/urls.py
Normal file
21
backend/apps/media/urls.py
Normal file
@@ -0,0 +1,21 @@
|
||||
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",
|
||||
),
|
||||
]
|
||||
189
backend/apps/media/views.py
Normal file
189
backend/apps/media/views.py
Normal file
@@ -0,0 +1,189 @@
|
||||
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)
|
||||
Reference in New Issue
Block a user