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

@@ -8,7 +8,7 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("accounts", "0006_alter_userprofile_avatar_and_more"),
("accounts", "0005_remove_user_insert_insert_remove_user_update_update_and_more"),
]
operations = [

View File

@@ -1,6 +1,5 @@
# Generated by Django 5.2.5 on 2025-08-29 15:29
# Generated by Django 5.2.5 on 2025-08-30 20:55
import cloudflare_images.field
import django.db.models.deletion
import pgtrigger.compiler
import pgtrigger.migrations
@@ -11,29 +10,13 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
(
"accounts",
"0005_remove_user_insert_insert_remove_user_update_update_and_more",
),
("accounts", "0008_remove_first_last_name_fields"),
("contenttypes", "0002_remove_content_type_name"),
("django_cloudflareimages_toolkit", "0001_initial"),
("pghistory", "0007_auto_20250421_0444"),
]
operations = [
migrations.AlterField(
model_name="userprofile",
name="avatar",
field=cloudflare_images.field.CloudflareImagesField(
blank=True, null=True, upload_to="", variant="public"
),
),
migrations.AlterField(
model_name="userprofileevent",
name="avatar",
field=cloudflare_images.field.CloudflareImagesField(
blank=True, null=True, upload_to="", variant="public"
),
),
migrations.CreateModel(
name="NotificationPreference",
fields=[
@@ -87,14 +70,6 @@ class Migration(migrations.Migration):
("milestone_reached_email", models.BooleanField(default=False)),
("milestone_reached_push", models.BooleanField(default=True)),
("milestone_reached_inapp", models.BooleanField(default=True)),
(
"user",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
related_name="notification_preference",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"verbose_name": "Notification Preference",
@@ -150,35 +125,6 @@ class Migration(migrations.Migration):
("milestone_reached_email", models.BooleanField(default=False)),
("milestone_reached_push", models.BooleanField(default=True)),
("milestone_reached_inapp", models.BooleanField(default=True)),
(
"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="accounts.notificationpreference",
),
),
(
"user",
models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"abstract": False,
@@ -245,23 +191,6 @@ class Migration(migrations.Migration):
("extra_data", models.JSONField(blank=True, default=dict)),
("created_at", models.DateTimeField(auto_now_add=True)),
("expires_at", models.DateTimeField(blank=True, null=True)),
(
"content_type",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="contenttypes.contenttype",
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="notifications",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"ordering": ["-created_at"],
@@ -324,52 +253,176 @@ class Migration(migrations.Migration):
("extra_data", models.JSONField(blank=True, default=dict)),
("created_at", models.DateTimeField(auto_now_add=True)),
("expires_at", models.DateTimeField(blank=True, null=True)),
(
"content_type",
models.ForeignKey(
blank=True,
db_constraint=False,
null=True,
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="accounts.usernotification",
),
),
(
"user",
models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"abstract": False,
},
),
pgtrigger.migrations.RemoveTrigger(
model_name="userprofile",
name="insert_insert",
),
pgtrigger.migrations.RemoveTrigger(
model_name="userprofile",
name="update_update",
),
migrations.AlterField(
model_name="userprofile",
name="avatar",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="django_cloudflareimages_toolkit.cloudflareimage",
),
),
migrations.AlterField(
model_name="userprofileevent",
name="avatar",
field=models.ForeignKey(
blank=True,
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="django_cloudflareimages_toolkit.cloudflareimage",
),
),
pgtrigger.migrations.AddTrigger(
model_name="userprofile",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "accounts_userprofileevent" ("avatar_id", "bio", "coaster_credits", "dark_ride_credits", "discord", "display_name", "flat_ride_credits", "id", "instagram", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "profile_id", "pronouns", "twitter", "user_id", "water_ride_credits", "youtube") VALUES (NEW."avatar_id", NEW."bio", NEW."coaster_credits", NEW."dark_ride_credits", NEW."discord", NEW."display_name", NEW."flat_ride_credits", NEW."id", NEW."instagram", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."profile_id", NEW."pronouns", NEW."twitter", NEW."user_id", NEW."water_ride_credits", NEW."youtube"); RETURN NULL;',
hash="a7ecdb1ac2821dea1fef4ec917eeaf6b8e4f09c8",
operation="INSERT",
pgid="pgtrigger_insert_insert_c09d7",
table="accounts_userprofile",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="userprofile",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "accounts_userprofileevent" ("avatar_id", "bio", "coaster_credits", "dark_ride_credits", "discord", "display_name", "flat_ride_credits", "id", "instagram", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "profile_id", "pronouns", "twitter", "user_id", "water_ride_credits", "youtube") VALUES (NEW."avatar_id", NEW."bio", NEW."coaster_credits", NEW."dark_ride_credits", NEW."discord", NEW."display_name", NEW."flat_ride_credits", NEW."id", NEW."instagram", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."profile_id", NEW."pronouns", NEW."twitter", NEW."user_id", NEW."water_ride_credits", NEW."youtube"); RETURN NULL;',
hash="81607e492ffea2a4c741452b860ee660374cc01d",
operation="UPDATE",
pgid="pgtrigger_update_update_87ef6",
table="accounts_userprofile",
when="AFTER",
),
),
),
migrations.AddField(
model_name="notificationpreference",
name="user",
field=models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
related_name="notification_preference",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AddField(
model_name="notificationpreferenceevent",
name="pgh_context",
field=models.ForeignKey(
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
to="pghistory.context",
),
),
migrations.AddField(
model_name="notificationpreferenceevent",
name="pgh_obj",
field=models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="events",
to="accounts.notificationpreference",
),
),
migrations.AddField(
model_name="notificationpreferenceevent",
name="user",
field=models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AddField(
model_name="usernotification",
name="content_type",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="contenttypes.contenttype",
),
),
migrations.AddField(
model_name="usernotification",
name="user",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="notifications",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AddField(
model_name="usernotificationevent",
name="content_type",
field=models.ForeignKey(
blank=True,
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="contenttypes.contenttype",
),
),
migrations.AddField(
model_name="usernotificationevent",
name="pgh_context",
field=models.ForeignKey(
db_constraint=False,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
to="pghistory.context",
),
),
migrations.AddField(
model_name="usernotificationevent",
name="pgh_obj",
field=models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="events",
to="accounts.usernotification",
),
),
migrations.AddField(
model_name="usernotificationevent",
name="user",
field=models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to=settings.AUTH_USER_MODEL,
),
),
pgtrigger.migrations.AddTrigger(
model_name="notificationpreference",
trigger=pgtrigger.compiler.Trigger(

View File

@@ -0,0 +1,58 @@
# Generated by Django 5.2.5 on 2025-08-30 20:57
from django.db import migrations, models
import django.db.models.deletion
def migrate_avatar_data(apps, schema_editor):
"""
Migrate avatar data from old CloudflareImageField to new ForeignKey structure.
Since we're transitioning to a new system, we'll just drop the old avatar column
and add the new avatar_id column for ForeignKey relationships.
"""
# This is a data migration - we'll handle the schema changes in the operations
pass
def reverse_migrate_avatar_data(apps, schema_editor):
"""
Reverse migration - not implemented as this is a one-way migration
"""
pass
class Migration(migrations.Migration):
dependencies = [
(
"accounts",
"0009_notificationpreference_notificationpreferenceevent_and_more",
),
("django_cloudflareimages_toolkit", "0001_initial"),
]
operations = [
# First, remove the old avatar column (CloudflareImageField)
migrations.RunSQL(
"ALTER TABLE accounts_userprofile DROP COLUMN IF EXISTS avatar;",
reverse_sql="-- Cannot reverse this operation"
),
# Add the new avatar_id column for ForeignKey
migrations.AddField(
model_name='userprofile',
name='avatar',
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to='django_cloudflareimages_toolkit.cloudflareimage'
),
),
# Run the data migration
migrations.RunPython(
migrate_avatar_data,
reverse_migrate_avatar_data,
),
]

View File

@@ -0,0 +1,25 @@
# Generated manually on 2025-08-30 to fix pghistory event table schema
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('accounts', '0010_auto_20250830_1657'),
('django_cloudflareimages_toolkit', '0001_initial'),
]
operations = [
# Remove the old avatar field from the event table
migrations.RunSQL(
"ALTER TABLE accounts_userprofileevent DROP COLUMN IF EXISTS avatar;",
reverse_sql="-- Cannot reverse this operation"
),
# Add the new avatar_id field to match the main table
migrations.RunSQL(
"ALTER TABLE accounts_userprofileevent ADD COLUMN avatar_id uuid;",
reverse_sql="ALTER TABLE accounts_userprofileevent DROP COLUMN avatar_id;"
),
]

View File

@@ -10,7 +10,6 @@ from datetime import timedelta
from django.utils import timezone
from apps.core.history import TrackedModel
import pghistory
from cloudflare_images.field import CloudflareImagesField
def generate_random_id(model_class, id_field):
@@ -160,7 +159,12 @@ class UserProfile(models.Model):
blank=True,
help_text="Legacy display name field - use User.display_name instead",
)
avatar = CloudflareImagesField(blank=True, null=True)
avatar = models.ForeignKey(
'django_cloudflareimages_toolkit.CloudflareImage',
on_delete=models.SET_NULL,
null=True,
blank=True
)
pronouns = models.CharField(max_length=50, blank=True)
bio = models.TextField(max_length=500, blank=True)
@@ -181,12 +185,26 @@ class UserProfile(models.Model):
"""
Return the avatar URL or generate a default letter-based avatar URL
"""
if self.avatar:
# Return Cloudflare Images URL with avatar variant
base_url = self.avatar.url
if '/public' in base_url:
return base_url.replace('/public', '/avatar')
return base_url
if self.avatar and self.avatar.is_uploaded:
# Try to get avatar variant first, fallback to public
avatar_url = self.avatar.get_url('avatar')
if avatar_url:
return avatar_url
# Fallback to public variant
public_url = self.avatar.get_url('public')
if public_url:
return public_url
# Last fallback - try any available variant
if self.avatar.variants:
if isinstance(self.avatar.variants, list) and self.avatar.variants:
return self.avatar.variants[0]
elif isinstance(self.avatar.variants, dict):
# Return first available variant
for variant_url in self.avatar.variants.values():
if variant_url:
return variant_url
# Generate default letter-based avatar using first letter of username
first_letter = self.user.username[0].upper() if self.user.username else "U"
@@ -197,21 +215,32 @@ class UserProfile(models.Model):
"""
Return avatar variants for different use cases
"""
if self.avatar:
base_url = self.avatar.url
if '/public' in base_url:
return {
"thumbnail": base_url.replace('/public', '/thumbnail'),
"avatar": base_url.replace('/public', '/avatar'),
"large": base_url.replace('/public', '/large'),
}
else:
# If no variant in URL, return the same URL for all variants
return {
"thumbnail": base_url,
"avatar": base_url,
"large": base_url,
}
if self.avatar and self.avatar.is_uploaded:
variants = {}
# Try to get specific variants
thumbnail_url = self.avatar.get_url('thumbnail')
avatar_url = self.avatar.get_url('avatar')
large_url = self.avatar.get_url('large')
public_url = self.avatar.get_url('public')
# Use specific variants if available, otherwise fallback to public or first available
fallback_url = public_url
if not fallback_url and self.avatar.variants:
if isinstance(self.avatar.variants, list) and self.avatar.variants:
fallback_url = self.avatar.variants[0]
elif isinstance(self.avatar.variants, dict):
fallback_url = next(iter(self.avatar.variants.values()), None)
variants = {
"thumbnail": thumbnail_url or fallback_url,
"avatar": avatar_url or fallback_url,
"large": large_url or fallback_url,
}
# Only return variants if we have at least one valid URL
if any(variants.values()):
return variants
# For default avatars, return the same URL for all variants
default_url = self.get_avatar_url()

View File

@@ -6,7 +6,7 @@ from django.utils import timezone
from datetime import timedelta
from django.contrib.sites.shortcuts import get_current_site
from .models import User, PasswordReset
from apps.email_service.services import EmailService
from django_forwardemail.services import EmailService
from django.template.loader import render_to_string
from typing import cast

View File

@@ -10,7 +10,7 @@ from django.db import transaction
from django.utils import timezone
from django.conf import settings
from django.contrib.sites.models import Site
from apps.email_service.services import EmailService
from django_forwardemail.services import EmailService
from .models import User, UserProfile, UserDeletionRequest

View File

@@ -15,7 +15,7 @@ from datetime import datetime, timedelta
import logging
from apps.accounts.models import User, UserNotification, NotificationPreference
from apps.email_service.services import EmailService
from django_forwardemail.services import EmailService
logger = logging.getLogger(__name__)

View File

@@ -6,10 +6,9 @@ social authentication providers while ensuring users never lock themselves
out of their accounts.
"""
from typing import Dict, List, Optional, Tuple, TYPE_CHECKING
from typing import Dict, List, Tuple, TYPE_CHECKING
from django.contrib.auth import get_user_model
from django.core.exceptions import ValidationError
from allauth.socialaccount.models import SocialAccount, SocialApp
from allauth.socialaccount.models import SocialApp
from allauth.socialaccount.providers import registry
from django.contrib.sites.shortcuts import get_current_site
from django.http import HttpRequest

View File

@@ -15,7 +15,7 @@ from typing import Dict, Any, Tuple, Optional
import logging
import secrets
import string
from datetime import timedelta, datetime
from datetime import datetime
from apps.accounts.models import User

View File

@@ -117,7 +117,7 @@ class UserDeletionServiceTest(TestCase):
# For now, we'll test the basic functionality
# Create deleted user first to ensure it exists
deleted_user = UserDeletionService.get_or_create_deleted_user()
UserDeletionService.get_or_create_deleted_user()
# Delete the test user
result = UserDeletionService.delete_user_preserve_submissions(self.user)
@@ -143,7 +143,7 @@ class UserDeletionServiceTest(TestCase):
with self.assertRaises(Exception):
with transaction.atomic():
# Start the deletion process
deleted_user = UserDeletionService.get_or_create_deleted_user()
UserDeletionService.get_or_create_deleted_user()
# Simulate an error
raise Exception("Simulated error during deletion")

View File

@@ -24,7 +24,7 @@ from apps.accounts.models import (
EmailVerification,
UserProfile,
)
from apps.email_service.services import EmailService
from django_forwardemail.services import EmailService
from apps.parks.models import ParkReview
from apps.rides.models import RideReview
from allauth.account.views import LoginView, SignupView