feat: Implement avatar upload system with Cloudflare integration

- Added migration to transition avatar data from CloudflareImageField to ForeignKey structure in UserProfile.
- Fixed UserProfileEvent avatar field to align with new avatar structure.
- Created serializers for social authentication, including connected and available providers.
- Developed request logging middleware for comprehensive request/response logging.
- Updated moderation and parks migrations to remove outdated triggers and adjust foreign key relationships.
- Enhanced rides migrations to ensure proper handling of image uploads and triggers.
- Introduced a test script for the 3-step avatar upload process, ensuring functionality with Cloudflare.
- Documented the fix for avatar upload issues, detailing root cause, implementation, and verification steps.
- Implemented automatic deletion of Cloudflare images upon avatar, park, and ride photo changes or removals.
This commit is contained in:
pacnpal
2025-08-30 21:20:25 -04:00
parent fb6726f89a
commit 9bed782784
75 changed files with 4571 additions and 1962 deletions

View File

@@ -1,32 +0,0 @@
# Generated by Django 5.2.5 on 2025-08-28 18:17
import cloudflare_images.field
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("rides", "0007_ridephoto_ridephotoevent_and_more"),
]
operations = [
migrations.AlterField(
model_name="ridephoto",
name="image",
field=cloudflare_images.field.CloudflareImagesField(
help_text="Ride photo stored on Cloudflare Images",
upload_to="",
variant="public",
),
),
migrations.AlterField(
model_name="ridephotoevent",
name="image",
field=cloudflare_images.field.CloudflareImagesField(
help_text="Ride photo stored on Cloudflare Images",
upload_to="",
variant="public",
),
),
]

View File

@@ -9,7 +9,7 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("rides", "0008_cloudflare_images_integration"),
("rides", "0007_ridephoto_ridephotoevent_and_more"),
]
operations = [

View File

@@ -7,7 +7,7 @@ from django.utils.text import slugify
def populate_ride_model_slugs(apps, schema_editor):
"""Populate unique slugs for existing RideModel records."""
RideModel = apps.get_model("rides", "RideModel")
Company = apps.get_model("rides", "Company")
apps.get_model("rides", "Company")
for ride_model in RideModel.objects.all():
# Generate base slug from manufacturer name + model name

View File

@@ -0,0 +1,133 @@
# Generated by Django 5.2.5 on 2025-08-30 21:41
import django.db.models.deletion
import pgtrigger.compiler
import pgtrigger.migrations
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("django_cloudflareimages_toolkit", "0001_initial"),
("rides", "0016_remove_ride_insert_insert_remove_ride_update_update_and_more"),
]
operations = [
pgtrigger.migrations.RemoveTrigger(
model_name="ridemodelphoto",
name="insert_insert",
),
pgtrigger.migrations.RemoveTrigger(
model_name="ridemodelphoto",
name="update_update",
),
pgtrigger.migrations.RemoveTrigger(
model_name="ridephoto",
name="insert_insert",
),
pgtrigger.migrations.RemoveTrigger(
model_name="ridephoto",
name="update_update",
),
migrations.AlterField(
model_name="ridemodelphoto",
name="image",
field=models.ForeignKey(
help_text="Photo of the ride model stored on Cloudflare Images",
on_delete=django.db.models.deletion.CASCADE,
to="django_cloudflareimages_toolkit.cloudflareimage",
),
),
migrations.AlterField(
model_name="ridemodelphotoevent",
name="image",
field=models.ForeignKey(
db_constraint=False,
help_text="Photo of the ride model stored on Cloudflare Images",
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="django_cloudflareimages_toolkit.cloudflareimage",
),
),
migrations.AlterField(
model_name="ridephoto",
name="image",
field=models.ForeignKey(
help_text="Ride photo stored on Cloudflare Images",
on_delete=django.db.models.deletion.CASCADE,
to="django_cloudflareimages_toolkit.cloudflareimage",
),
),
migrations.AlterField(
model_name="ridephotoevent",
name="image",
field=models.ForeignKey(
db_constraint=False,
help_text="Ride photo stored on Cloudflare Images",
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="django_cloudflareimages_toolkit.cloudflareimage",
),
),
pgtrigger.migrations.AddTrigger(
model_name="ridemodelphoto",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "rides_ridemodelphotoevent" ("alt_text", "caption", "copyright_info", "created_at", "id", "image_id", "is_primary", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "photo_type", "photographer", "ride_model_id", "source", "updated_at") VALUES (NEW."alt_text", NEW."caption", NEW."copyright_info", NEW."created_at", NEW."id", NEW."image_id", NEW."is_primary", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."photo_type", NEW."photographer", NEW."ride_model_id", NEW."source", NEW."updated_at"); RETURN NULL;',
hash="fa289c31e25da0c08740d9e9c4072f3e4df81c42",
operation="INSERT",
pgid="pgtrigger_insert_insert_c5e58",
table="rides_ridemodelphoto",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="ridemodelphoto",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "rides_ridemodelphotoevent" ("alt_text", "caption", "copyright_info", "created_at", "id", "image_id", "is_primary", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "photo_type", "photographer", "ride_model_id", "source", "updated_at") VALUES (NEW."alt_text", NEW."caption", NEW."copyright_info", NEW."created_at", NEW."id", NEW."image_id", NEW."is_primary", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."photo_type", NEW."photographer", NEW."ride_model_id", NEW."source", NEW."updated_at"); RETURN NULL;',
hash="1ead1d3fd3dd553f585ae76aa6f3215314322ff4",
operation="UPDATE",
pgid="pgtrigger_update_update_3afcd",
table="rides_ridemodelphoto",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="ridephoto",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "rides_ridephotoevent" ("alt_text", "caption", "created_at", "date_taken", "id", "image_id", "is_approved", "is_primary", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "photo_type", "ride_id", "updated_at", "uploaded_by_id") VALUES (NEW."alt_text", NEW."caption", NEW."created_at", NEW."date_taken", NEW."id", NEW."image_id", NEW."is_approved", NEW."is_primary", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."photo_type", NEW."ride_id", NEW."updated_at", NEW."uploaded_by_id"); RETURN NULL;',
hash="51487ac871d9d90c75f695f106e5f1f43fdb00c6",
operation="INSERT",
pgid="pgtrigger_insert_insert_0043a",
table="rides_ridephoto",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="ridephoto",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "rides_ridephotoevent" ("alt_text", "caption", "created_at", "date_taken", "id", "image_id", "is_approved", "is_primary", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "photo_type", "ride_id", "updated_at", "uploaded_by_id") VALUES (NEW."alt_text", NEW."caption", NEW."created_at", NEW."date_taken", NEW."id", NEW."image_id", NEW."is_approved", NEW."is_primary", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."photo_type", NEW."ride_id", NEW."updated_at", NEW."uploaded_by_id"); RETURN NULL;',
hash="6147489f087c144f887386548cba269ffc193094",
operation="UPDATE",
pgid="pgtrigger_update_update_93a7e",
table="rides_ridephoto",
when="AFTER",
),
),
),
]

View File

@@ -9,7 +9,6 @@ from django.db import models
from django.conf import settings
from apps.core.history import TrackedModel
from apps.core.services.media_service import MediaService
from cloudflare_images.field import CloudflareImagesField
import pghistory
@@ -37,8 +36,10 @@ class RidePhoto(TrackedModel):
"rides.Ride", on_delete=models.CASCADE, related_name="photos"
)
image = CloudflareImagesField(
variant="public", help_text="Ride photo stored on Cloudflare Images"
image = models.ForeignKey(
'django_cloudflareimages_toolkit.CloudflareImage',
on_delete=models.CASCADE,
help_text="Ride photo stored on Cloudflare Images"
)
caption = models.CharField(max_length=255, blank=True)

View File

@@ -362,8 +362,10 @@ class RideModelPhoto(TrackedModel):
ride_model = models.ForeignKey(
RideModel, on_delete=models.CASCADE, related_name="photos"
)
image = models.ImageField(
upload_to="ride_models/photos/", help_text="Photo of the ride model"
image = models.ForeignKey(
'django_cloudflareimages_toolkit.CloudflareImage',
on_delete=models.CASCADE,
help_text="Photo of the ride model stored on Cloudflare Images"
)
caption = models.CharField(max_length=500, blank=True)
alt_text = models.CharField(max_length=255, blank=True)
@@ -624,9 +626,30 @@ class Ride(TrackedModel):
return f"{self.name} at {self.park.name}"
def save(self, *args, **kwargs) -> None:
# Handle slug generation and conflicts
if not self.slug:
self.slug = slugify(self.name)
# Check for slug conflicts when park changes or slug is new
original_ride = None
if self.pk:
try:
original_ride = Ride.objects.get(pk=self.pk)
except Ride.DoesNotExist:
pass
# If park changed or this is a new ride, ensure slug uniqueness within the park
park_changed = original_ride and original_ride.park_id != self.park_id
if not self.pk or park_changed:
self._ensure_unique_slug_in_park()
# Handle park area validation when park changes
if park_changed and self.park_area:
# Check if park_area belongs to the new park
if self.park_area.park_id != self.park_id:
# Clear park_area if it doesn't belong to the new park
self.park_area = None
# Generate frontend URLs
if self.park:
frontend_domain = getattr(
@@ -637,6 +660,73 @@ class Ride(TrackedModel):
super().save(*args, **kwargs)
def _ensure_unique_slug_in_park(self) -> None:
"""Ensure the ride's slug is unique within its park."""
base_slug = slugify(self.name)
self.slug = base_slug
counter = 1
while (
Ride.objects.filter(park=self.park, slug=self.slug)
.exclude(pk=self.pk)
.exists()
):
self.slug = f"{base_slug}-{counter}"
counter += 1
def move_to_park(self, new_park, clear_park_area=True):
"""
Move this ride to a different park with proper handling of related data.
Args:
new_park: The new Park instance to move the ride to
clear_park_area: Whether to clear park_area (default True, since areas are park-specific)
Returns:
dict: Summary of changes made
"""
from django.apps import apps
old_park = self.park
old_url = self.url
old_park_area = self.park_area
# Update park
self.park = new_park
# Handle park area
if clear_park_area:
self.park_area = None
# Save will handle slug conflicts and URL updates
self.save()
# Return summary of changes
changes = {
'old_park': {
'id': old_park.id,
'name': old_park.name,
'slug': old_park.slug
},
'new_park': {
'id': new_park.id,
'name': new_park.name,
'slug': new_park.slug
},
'url_changed': old_url != self.url,
'old_url': old_url,
'new_url': self.url,
'park_area_cleared': clear_park_area and old_park_area is not None,
'old_park_area': {
'id': old_park_area.id,
'name': old_park_area.name
} if old_park_area else None,
'slug_changed': self.slug != slugify(self.name),
'final_slug': self.slug
}
return changes
@pghistory.track()
class RollerCoasterStats(models.Model):

View File

@@ -3,7 +3,6 @@ from django.shortcuts import get_object_or_404, render
from django.urls import reverse
from django.db.models import Q
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.contenttypes.models import ContentType
from django.http import HttpRequest, HttpResponse, Http404
from django.db.models import Count
from .models.rides import Ride, RideModel, Categories
@@ -13,7 +12,6 @@ from .forms.search import MasterFilterForm
from .services.search import RideSearchService
from apps.parks.models import Park
from apps.moderation.mixins import EditSubmissionMixin, HistoryMixin
from apps.moderation.models import EditSubmission
from apps.moderation.services import ModerationService
from .models.rankings import RideRanking, RankingSnapshot
from .services.ranking_service import RideRankingService