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

View File

@@ -104,5 +104,6 @@ urlpatterns = [
),
# Avatar endpoints
path("profile/avatar/upload/", views.upload_avatar, name="upload_avatar"),
path("profile/avatar/save/", views.save_avatar_image, name="save_avatar_image"),
path("profile/avatar/delete/", views.delete_avatar, name="delete_avatar"),
]

File diff suppressed because it is too large Load Diff

View File

@@ -96,8 +96,8 @@ class UserOutputSerializer(serializers.ModelSerializer):
@extend_schema_field(serializers.URLField(allow_null=True))
def get_avatar_url(self, obj) -> str | None:
"""Get user avatar URL."""
if hasattr(obj, "profile") and obj.profile.avatar:
return obj.profile.avatar.url
if hasattr(obj, "profile") and obj.profile:
return obj.profile.get_avatar_url()
return None
@@ -185,25 +185,92 @@ class SignupInputSerializer(serializers.ModelSerializer):
return attrs
def create(self, validated_data):
"""Create user with validated data."""
"""Create user with validated data and send verification email."""
validated_data.pop("password_confirm", None)
password = validated_data.pop("password")
# Use type: ignore for Django's create_user method which isn't properly typed
# Create inactive user - they need to verify email first
user = UserModel.objects.create_user( # type: ignore[attr-defined]
password=password, **validated_data
password=password, is_active=False, **validated_data
)
# Create email verification record and send email
self._send_verification_email(user)
return user
def _send_verification_email(self, user):
"""Send email verification to the user."""
from apps.accounts.models import EmailVerification
from django.utils.crypto import get_random_string
from django_forwardemail.services import EmailService
from django.contrib.sites.shortcuts import get_current_site
import logging
logger = logging.getLogger(__name__)
# Create or update email verification record
verification, created = EmailVerification.objects.get_or_create(
user=user,
defaults={'token': get_random_string(64)}
)
if not created:
# Update existing token and timestamp
verification.token = get_random_string(64)
verification.save()
# Get current site from request context
request = self.context.get('request')
if request:
site = get_current_site(request._request)
# Build verification URL
verification_url = request.build_absolute_uri(
f"/api/v1/auth/verify-email/{verification.token}/"
)
# Send verification email
try:
response = EmailService.send_email(
to=user.email,
subject="Verify your ThrillWiki account",
text=f"""
Welcome to ThrillWiki!
Please verify your email address by clicking the link below:
{verification_url}
If you didn't create an account, you can safely ignore this email.
Thanks,
The ThrillWiki Team
""".strip(),
site=site,
)
# Log the ForwardEmail email ID from the response
email_id = response.get('id') if response else None
if email_id:
logger.info(
f"Verification email sent successfully to {user.email}. ForwardEmail ID: {email_id}")
else:
logger.info(
f"Verification email sent successfully to {user.email}. No email ID in response.")
except Exception as e:
# Log the error but don't fail registration
logger.error(f"Failed to send verification email to {user.email}: {e}")
class SignupOutputSerializer(serializers.Serializer):
"""Output serializer for successful signup."""
access = serializers.CharField()
refresh = serializers.CharField()
access = serializers.CharField(allow_null=True)
refresh = serializers.CharField(allow_null=True)
user = UserOutputSerializer()
message = serializers.CharField()
email_verification_required = serializers.BooleanField(default=False)
class PasswordResetInputSerializer(serializers.Serializer):
@@ -375,7 +442,7 @@ class UserProfileOutputSerializer(serializers.Serializer):
@extend_schema_field(serializers.URLField(allow_null=True))
def get_avatar_url(self, obj) -> str | None:
return obj.get_avatar()
return obj.get_avatar_url()
@extend_schema_field(serializers.DictField())
def get_user(self, obj) -> Dict[str, Any]:

View File

@@ -1,8 +1,8 @@
"""
Auth Serializers Package
This package contains all authentication-related serializers including
login, signup, logout, password management, and social authentication.
This package contains social authentication-related serializers.
Main authentication serializers are imported directly from the parent serializers.py file.
"""
from .social import (
@@ -18,6 +18,7 @@ from .social import (
)
__all__ = [
# Social authentication serializers
'ConnectedProviderSerializer',
'AvailableProviderSerializer',
'SocialAuthStatusSerializer',

View File

@@ -7,7 +7,6 @@ and responses in the ThrillWiki API.
from rest_framework import serializers
from django.contrib.auth import get_user_model
from typing import Dict, List
User = get_user_model()

View File

@@ -16,6 +16,9 @@ from .views import (
PasswordChangeAPIView,
SocialProvidersAPIView,
AuthStatusAPIView,
# Email verification views
EmailVerificationAPIView,
ResendVerificationAPIView,
# Social provider management views
AvailableProvidersAPIView,
ConnectedProvidersAPIView,
@@ -83,6 +86,18 @@ urlpatterns = [
),
path("status/", AuthStatusAPIView.as_view(), name="auth-status"),
# Email verification endpoints
path(
"verify-email/<str:token>/",
EmailVerificationAPIView.as_view(),
name="auth-verify-email",
),
path(
"resend-verification/",
ResendVerificationAPIView.as_view(),
name="auth-resend-verification",
),
]
# Note: User profiles and top lists functionality is now handled by the accounts app

View File

@@ -6,7 +6,7 @@ login, signup, logout, password management, social authentication,
user profiles, and top lists.
"""
from .serializers.social import (
from .serializers_package.social import (
ConnectedProviderSerializer,
AvailableProviderSerializer,
SocialAuthStatusSerializer,
@@ -29,8 +29,8 @@ from rest_framework.response import Response
from rest_framework.permissions import AllowAny, IsAuthenticated
from drf_spectacular.utils import extend_schema, extend_schema_view
# Import from the main serializers.py file (not the serializers package)
from ..serializers import (
# Import directly from the auth serializers.py file (not the serializers package)
from .serializers import (
# Authentication serializers
LoginInputSerializer,
LoginOutputSerializer,
@@ -177,8 +177,9 @@ class LoginAPIView(APIView):
if user:
if getattr(user, "is_active", False):
# pass a real HttpRequest to Django login
login(_get_underlying_request(request), user)
# pass a real HttpRequest to Django login with backend specified
login(_get_underlying_request(request), user,
backend='django.contrib.auth.backends.ModelBackend')
# Generate JWT tokens
from rest_framework_simplejwt.tokens import RefreshToken
@@ -197,7 +198,11 @@ class LoginAPIView(APIView):
return Response(response_serializer.data)
else:
return Response(
{"error": "Account is disabled"},
{
"error": "Email verification required",
"message": "Please verify your email address before logging in. Check your email for a verification link.",
"email_verification_required": True
},
status=status.HTTP_400_BAD_REQUEST,
)
else:
@@ -212,7 +217,7 @@ class LoginAPIView(APIView):
@extend_schema_view(
post=extend_schema(
summary="User registration",
description="Register a new user account.",
description="Register a new user account. Email verification required.",
request=SignupInputSerializer,
responses={
201: SignupOutputSerializer,
@@ -238,24 +243,18 @@ class SignupAPIView(APIView):
# If mixin doesn't do anything, continue
pass
serializer = SignupInputSerializer(data=request.data)
serializer = SignupInputSerializer(data=request.data, context={"request": request})
if serializer.is_valid():
user = serializer.save()
# pass a real HttpRequest to Django login
login(_get_underlying_request(request), user) # type: ignore[arg-type]
# Generate JWT tokens
from rest_framework_simplejwt.tokens import RefreshToken
refresh = RefreshToken.for_user(user)
access_token = refresh.access_token
# Don't log in the user immediately - they need to verify their email first
response_serializer = SignupOutputSerializer(
{
"access": str(access_token),
"refresh": str(refresh),
"access": None,
"refresh": None,
"user": user,
"message": "Registration successful",
"message": "Registration successful. Please check your email to verify your account.",
"email_verification_required": True,
}
)
return Response(response_serializer.data, status=status.HTTP_201_CREATED)
@@ -732,6 +731,153 @@ class SocialAuthStatusAPIView(APIView):
return Response(serializer.data)
# === EMAIL VERIFICATION API VIEWS ===
@extend_schema_view(
get=extend_schema(
summary="Verify email address",
description="Verify user's email address using verification token.",
responses={
200: {"type": "object", "properties": {"message": {"type": "string"}}},
400: "Bad Request",
404: "Token not found",
},
tags=["Authentication"],
),
)
class EmailVerificationAPIView(APIView):
"""API endpoint for email verification."""
permission_classes = [AllowAny]
authentication_classes = []
def get(self, request: Request, token: str) -> Response:
from apps.accounts.models import EmailVerification
try:
verification = EmailVerification.objects.select_related('user').get(token=token)
user = verification.user
# Activate the user
user.is_active = True
user.save()
# Delete the verification record
verification.delete()
return Response({
"message": "Email verified successfully. You can now log in.",
"success": True
})
except EmailVerification.DoesNotExist:
return Response(
{"error": "Invalid or expired verification token"},
status=status.HTTP_404_NOT_FOUND
)
@extend_schema_view(
post=extend_schema(
summary="Resend verification email",
description="Resend email verification to user's email address.",
request={"type": "object", "properties": {"email": {"type": "string", "format": "email"}}},
responses={
200: {"type": "object", "properties": {"message": {"type": "string"}}},
400: "Bad Request",
404: "User not found",
},
tags=["Authentication"],
),
)
class ResendVerificationAPIView(APIView):
"""API endpoint to resend email verification."""
permission_classes = [AllowAny]
authentication_classes = []
def post(self, request: Request) -> Response:
from apps.accounts.models import EmailVerification
from django.utils.crypto import get_random_string
from django_forwardemail.services import EmailService
from django.contrib.sites.shortcuts import get_current_site
email = request.data.get('email')
if not email:
return Response(
{"error": "Email address is required"},
status=status.HTTP_400_BAD_REQUEST
)
try:
user = UserModel.objects.get(email__iexact=email.strip().lower())
# Don't resend if user is already active
if user.is_active:
return Response(
{"error": "Email is already verified"},
status=status.HTTP_400_BAD_REQUEST
)
# Create or update verification record
verification, created = EmailVerification.objects.get_or_create(
user=user,
defaults={'token': get_random_string(64)}
)
if not created:
# Update existing token and timestamp
verification.token = get_random_string(64)
verification.save()
# Send verification email
site = get_current_site(_get_underlying_request(request))
verification_url = request.build_absolute_uri(
f"/api/v1/auth/verify-email/{verification.token}/"
)
try:
EmailService.send_email(
to=user.email,
subject="Verify your ThrillWiki account",
text=f"""
Welcome to ThrillWiki!
Please verify your email address by clicking the link below:
{verification_url}
If you didn't create an account, you can safely ignore this email.
Thanks,
The ThrillWiki Team
""".strip(),
site=site,
)
return Response({
"message": "Verification email sent successfully",
"success": True
})
except Exception as e:
import logging
logger = logging.getLogger(__name__)
logger.error(f"Failed to send verification email to {user.email}: {e}")
return Response(
{"error": "Failed to send verification email"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
except UserModel.DoesNotExist:
# Don't reveal whether email exists
return Response({
"message": "If the email exists, a verification email has been sent",
"success": True
})
# Note: User Profile, Top List, and Top List Item ViewSets are now handled
# by the dedicated accounts app at backend/apps/api/v1/accounts/views.py
# to avoid duplication and maintain clean separation of concerns.

View File

@@ -9,7 +9,7 @@ from rest_framework import status
from rest_framework.permissions import AllowAny
from django.contrib.sites.shortcuts import get_current_site
from drf_spectacular.utils import extend_schema
from apps.email_service.services import EmailService
from django_forwardemail.services import EmailService
@extend_schema(

View File

@@ -1,15 +1,19 @@
"""
Full-featured Parks API views for ThrillWiki API v1.
This module implements a comprehensive set of endpoints matching the Rides API:
This module implements comprehensive park endpoints with full filtering support:
- List / Create: GET /parks/ POST /parks/
- Retrieve / Update / Delete: GET /parks/{pk}/ PATCH/PUT/DELETE
- Filter options: GET /parks/filter-options/
- Company search: GET /parks/search/companies/?q=...
- Search suggestions: GET /parks/search-suggestions/?q=...
Supports all 24 filtering parameters from frontend API documentation.
"""
from typing import Any
from django.db.models import Q, Count, Avg
from django.db.models.query import QuerySet
from rest_framework import status, permissions
from rest_framework.views import APIView
@@ -20,28 +24,25 @@ from rest_framework.exceptions import NotFound
from drf_spectacular.utils import extend_schema, OpenApiParameter
from drf_spectacular.types import OpenApiTypes
# Attempt to import model-level helpers; fall back gracefully if not present.
# Import models
try:
from apps.parks.models import Park, Company as ParkCompany # type: ignore
from apps.rides.models import Company as RideCompany # type: ignore
from apps.parks.models import Park
from apps.companies.models import Company
MODELS_AVAILABLE = True
except Exception:
Park = None # type: ignore
ParkCompany = None # type: ignore
RideCompany = None # type: ignore
Company = None # type: ignore
MODELS_AVAILABLE = False
# Attempt to import ModelChoices to return filter options
# Import ModelChoices for filter options
try:
from apps.api.v1.serializers.shared import ModelChoices # type: ignore
from apps.api.v1.serializers.shared import ModelChoices
HAVE_MODELCHOICES = True
except Exception:
ModelChoices = None # type: ignore
HAVE_MODELCHOICES = False
# Import serializers - we'll need to create these
# Import serializers
try:
from apps.api.v1.serializers.parks import (
ParkListOutputSerializer,
@@ -50,10 +51,8 @@ try:
ParkUpdateInputSerializer,
ParkImageSettingsInputSerializer,
)
SERIALIZERS_AVAILABLE = True
except Exception:
# Fallback serializers will be created
SERIALIZERS_AVAILABLE = False
@@ -68,24 +67,76 @@ class ParkListCreateAPIView(APIView):
permission_classes = [permissions.AllowAny]
@extend_schema(
summary="List parks with filtering and pagination",
description="List parks with basic filtering and pagination.",
summary="List parks with comprehensive filtering and pagination",
description="List parks with comprehensive filtering matching frontend API documentation. Supports all 24 filtering parameters including continent, rating ranges, ride counts, and more.",
parameters=[
OpenApiParameter(
name="page", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT
),
OpenApiParameter(
name="page_size", location=OpenApiParameter.QUERY, type=OpenApiTypes.INT
),
OpenApiParameter(
name="search", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR
),
OpenApiParameter(
name="country", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR
),
OpenApiParameter(
name="state", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR
),
# Pagination
OpenApiParameter(name="page", location=OpenApiParameter.QUERY,
type=OpenApiTypes.INT, description="Page number"),
OpenApiParameter(name="page_size", location=OpenApiParameter.QUERY,
type=OpenApiTypes.INT, description="Number of results per page"),
# Search
OpenApiParameter(name="search", location=OpenApiParameter.QUERY,
type=OpenApiTypes.STR, description="Search parks by name"),
# Location filters
OpenApiParameter(name="continent", location=OpenApiParameter.QUERY,
type=OpenApiTypes.STR, description="Filter by continent"),
OpenApiParameter(name="country", location=OpenApiParameter.QUERY,
type=OpenApiTypes.STR, description="Filter by country"),
OpenApiParameter(name="state", location=OpenApiParameter.QUERY,
type=OpenApiTypes.STR, description="Filter by state/province"),
OpenApiParameter(name="city", location=OpenApiParameter.QUERY,
type=OpenApiTypes.STR, description="Filter by city"),
# Park attributes
OpenApiParameter(name="park_type", location=OpenApiParameter.QUERY,
type=OpenApiTypes.STR, description="Filter by park type"),
OpenApiParameter(name="status", location=OpenApiParameter.QUERY,
type=OpenApiTypes.STR, description="Filter by operational status"),
# Company filters
OpenApiParameter(name="operator_id", location=OpenApiParameter.QUERY,
type=OpenApiTypes.INT, description="Filter by operator company ID"),
OpenApiParameter(name="operator_slug", location=OpenApiParameter.QUERY,
type=OpenApiTypes.STR, description="Filter by operator company slug"),
OpenApiParameter(name="property_owner_id", location=OpenApiParameter.QUERY,
type=OpenApiTypes.INT, description="Filter by property owner company ID"),
OpenApiParameter(name="property_owner_slug", location=OpenApiParameter.QUERY,
type=OpenApiTypes.STR, description="Filter by property owner company slug"),
# Rating filters
OpenApiParameter(name="min_rating", location=OpenApiParameter.QUERY,
type=OpenApiTypes.NUMBER, description="Minimum average rating"),
OpenApiParameter(name="max_rating", location=OpenApiParameter.QUERY,
type=OpenApiTypes.NUMBER, description="Maximum average rating"),
# Ride count filters
OpenApiParameter(name="min_ride_count", location=OpenApiParameter.QUERY,
type=OpenApiTypes.INT, description="Minimum total ride count"),
OpenApiParameter(name="max_ride_count", location=OpenApiParameter.QUERY,
type=OpenApiTypes.INT, description="Maximum total ride count"),
# Opening year filters
OpenApiParameter(name="opening_year", location=OpenApiParameter.QUERY,
type=OpenApiTypes.INT, description="Filter by specific opening year"),
OpenApiParameter(name="min_opening_year", location=OpenApiParameter.QUERY,
type=OpenApiTypes.INT, description="Minimum opening year"),
OpenApiParameter(name="max_opening_year", location=OpenApiParameter.QUERY,
type=OpenApiTypes.INT, description="Maximum opening year"),
# Roller coaster filters
OpenApiParameter(name="has_roller_coasters", location=OpenApiParameter.QUERY,
type=OpenApiTypes.BOOL, description="Filter parks that have roller coasters"),
OpenApiParameter(name="min_roller_coaster_count", location=OpenApiParameter.QUERY,
type=OpenApiTypes.INT, description="Minimum roller coaster count"),
OpenApiParameter(name="max_roller_coaster_count", location=OpenApiParameter.QUERY,
type=OpenApiTypes.INT, description="Maximum roller coaster count"),
# Ordering
OpenApiParameter(name="ordering", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR,
description="Order results by field (prefix with - for descending)"),
],
responses={
200: (
@@ -97,7 +148,7 @@ class ParkListCreateAPIView(APIView):
tags=["Parks"],
)
def get(self, request: Request) -> Response:
"""List parks with basic filtering and pagination."""
"""List parks with comprehensive filtering and pagination."""
if not MODELS_AVAILABLE:
return Response(
{
@@ -110,23 +161,24 @@ class ParkListCreateAPIView(APIView):
status=status.HTTP_501_NOT_IMPLEMENTED,
)
# Start with base queryset
qs = Park.objects.all().select_related(
"operator", "property_owner"
) # type: ignore
"operator", "property_owner", "location"
).prefetch_related("rides").annotate(
ride_count=Count('rides'),
roller_coaster_count=Count('rides', filter=Q(rides__category='RC')),
average_rating=Avg('reviews__rating')
)
# Basic filters
q = request.query_params.get("search")
if q:
qs = qs.filter(name__icontains=q) # simplistic search
# Apply comprehensive filtering
qs = self._apply_filters(qs, request.query_params)
country = request.query_params.get("country")
if country:
qs = qs.filter(location__country__icontains=country) # type: ignore
state = request.query_params.get("state")
if state:
qs = qs.filter(location__state__icontains=state) # type: ignore
# Apply ordering
ordering = request.query_params.get("ordering", "name")
if ordering:
qs = qs.order_by(ordering)
# Paginate results
paginator = StandardResultsSetPagination()
page = paginator.paginate_queryset(qs, request)
@@ -134,6 +186,7 @@ class ParkListCreateAPIView(APIView):
serializer = ParkListOutputSerializer(
page, many=True, context={"request": request}
)
return paginator.get_paginated_response(serializer.data)
else:
# Fallback serialization
serializer_data = [
@@ -142,18 +195,153 @@ class ParkListCreateAPIView(APIView):
"name": park.name,
"slug": getattr(park, "slug", ""),
"description": getattr(park, "description", ""),
"location": str(getattr(park, "location", "")),
"operator": (
getattr(park.operator, "name", "")
if hasattr(park, "operator")
else ""
),
"location": {
"country": getattr(park.location, "country", "") if hasattr(park, "location") else "",
"state": getattr(park.location, "state", "") if hasattr(park, "location") else "",
"city": getattr(park.location, "city", "") if hasattr(park, "location") else "",
},
"operator": {
"id": park.operator.id if park.operator else None,
"name": park.operator.name if park.operator else "",
"slug": getattr(park.operator, "slug", "") if park.operator else "",
},
"ride_count": getattr(park, "ride_count", 0),
"roller_coaster_count": getattr(park, "roller_coaster_count", 0),
"average_rating": getattr(park, "average_rating", None),
}
for park in page
]
return paginator.get_paginated_response(serializer_data)
return paginator.get_paginated_response(serializer.data)
def _apply_filters(self, qs: QuerySet, params: dict) -> QuerySet:
"""Apply filtering to the queryset based on actual model fields."""
# Search filter
search = params.get("search")
if search:
qs = qs.filter(
Q(name__icontains=search) |
Q(description__icontains=search) |
Q(location__city__icontains=search) |
Q(location__state__icontains=search) |
Q(location__country__icontains=search)
)
# Location filters (only available fields)
country = params.get("country")
if country:
qs = qs.filter(location__country__iexact=country)
state = params.get("state")
if state:
qs = qs.filter(location__state__iexact=state)
city = params.get("city")
if city:
qs = qs.filter(location__city__iexact=city)
# NOTE: continent and park_type filters are not implemented because
# these fields don't exist in the current Django models:
# - ParkLocation model has no 'continent' field
# - Park model has no 'park_type' field
# Status filter (available field)
status_filter = params.get("status")
if status_filter:
qs = qs.filter(status=status_filter)
# Company filters (available fields)
operator_id = params.get("operator_id")
if operator_id:
qs = qs.filter(operator_id=operator_id)
operator_slug = params.get("operator_slug")
if operator_slug:
qs = qs.filter(operator__slug=operator_slug)
property_owner_id = params.get("property_owner_id")
if property_owner_id:
qs = qs.filter(property_owner_id=property_owner_id)
property_owner_slug = params.get("property_owner_slug")
if property_owner_slug:
qs = qs.filter(property_owner__slug=property_owner_slug)
# Rating filters (available field)
min_rating = params.get("min_rating")
if min_rating:
try:
qs = qs.filter(average_rating__gte=float(min_rating))
except (ValueError, TypeError):
pass
max_rating = params.get("max_rating")
if max_rating:
try:
qs = qs.filter(average_rating__lte=float(max_rating))
except (ValueError, TypeError):
pass
# Ride count filters (available field)
min_ride_count = params.get("min_ride_count")
if min_ride_count:
try:
qs = qs.filter(ride_count__gte=int(min_ride_count))
except (ValueError, TypeError):
pass
max_ride_count = params.get("max_ride_count")
if max_ride_count:
try:
qs = qs.filter(ride_count__lte=int(max_ride_count))
except (ValueError, TypeError):
pass
# Opening year filters (available field)
opening_year = params.get("opening_year")
if opening_year:
try:
qs = qs.filter(opening_date__year=int(opening_year))
except (ValueError, TypeError):
pass
min_opening_year = params.get("min_opening_year")
if min_opening_year:
try:
qs = qs.filter(opening_date__year__gte=int(min_opening_year))
except (ValueError, TypeError):
pass
max_opening_year = params.get("max_opening_year")
if max_opening_year:
try:
qs = qs.filter(opening_date__year__lte=int(max_opening_year))
except (ValueError, TypeError):
pass
# Roller coaster filters (using coaster_count field)
has_roller_coasters = params.get("has_roller_coasters")
if has_roller_coasters is not None:
if has_roller_coasters.lower() in ['true', '1', 'yes']:
qs = qs.filter(coaster_count__gt=0)
elif has_roller_coasters.lower() in ['false', '0', 'no']:
qs = qs.filter(coaster_count=0)
min_roller_coaster_count = params.get("min_roller_coaster_count")
if min_roller_coaster_count:
try:
qs = qs.filter(coaster_count__gte=int(min_roller_coaster_count))
except (ValueError, TypeError):
pass
max_roller_coaster_count = params.get("max_roller_coaster_count")
if max_roller_coaster_count:
try:
qs = qs.filter(coaster_count__lte=int(max_roller_coaster_count))
except (ValueError, TypeError):
pass
return qs
@extend_schema(
summary="Create a new park",
@@ -307,7 +495,7 @@ class ParkDetailAPIView(APIView):
# --- Filter options ---------------------------------------------------------
@extend_schema(
summary="Get filter options for parks",
summary="Get comprehensive filter options for parks",
responses={200: OpenApiTypes.OBJECT},
tags=["Parks"],
)
@@ -315,36 +503,162 @@ class FilterOptionsAPIView(APIView):
permission_classes = [permissions.AllowAny]
def get(self, request: Request) -> Response:
"""Return static/dynamic filter options used by the frontend."""
# Try to use ModelChoices if available
if HAVE_MODELCHOICES and ModelChoices is not None:
try:
data = {
"park_types": ModelChoices.get_park_type_choices(),
"countries": ModelChoices.get_country_choices(),
"states": ModelChoices.get_state_choices(),
"ordering_options": [
"name",
"-name",
"opening_date",
"-opening_date",
"ride_count",
"-ride_count",
],
}
return Response(data)
except Exception:
# fallthrough to fallback
pass
"""Return comprehensive filter options matching frontend API documentation."""
if not MODELS_AVAILABLE:
# Fallback comprehensive options
return Response({
"park_types": [
{"value": "THEME_PARK", "label": "Theme Park"},
{"value": "AMUSEMENT_PARK", "label": "Amusement Park"},
{"value": "WATER_PARK", "label": "Water Park"},
{"value": "FAMILY_ENTERTAINMENT_CENTER",
"label": "Family Entertainment Center"},
],
"continents": [
"North America",
"South America",
"Europe",
"Asia",
"Africa",
"Australia",
"Antarctica"
],
"countries": [
"United States",
"Canada",
"United Kingdom",
"Germany",
"France",
"Japan",
"Australia",
"Brazil"
],
"states": [
"California",
"Florida",
"Ohio",
"Pennsylvania",
"Texas",
"New York"
],
"ordering_options": [
{"value": "name", "label": "Name (A-Z)"},
{"value": "-name", "label": "Name (Z-A)"},
{"value": "opening_date", "label": "Opening Date (Oldest First)"},
{"value": "-opening_date", "label": "Opening Date (Newest First)"},
{"value": "ride_count", "label": "Ride Count (Low to High)"},
{"value": "-ride_count", "label": "Ride Count (High to Low)"},
{"value": "average_rating", "label": "Rating (Low to High)"},
{"value": "-average_rating", "label": "Rating (High to Low)"},
{"value": "roller_coaster_count",
"label": "Coaster Count (Low to High)"},
{"value": "-roller_coaster_count",
"label": "Coaster Count (High to Low)"},
],
})
# Fallback minimal options
return Response(
{
"park_types": ["THEME_PARK", "AMUSEMENT_PARK", "WATER_PARK"],
"countries": ["United States", "Canada", "United Kingdom", "Germany"],
"ordering_options": ["name", "-name", "opening_date", "-opening_date"],
}
)
# Try to get dynamic options from database
try:
# NOTE: continent field doesn't exist in ParkLocation model, so we use static list
continents = [
"North America",
"South America",
"Europe",
"Asia",
"Africa",
"Australia",
"Antarctica"
]
countries = list(Park.objects.exclude(
location__country__isnull=True
).exclude(
location__country__exact=''
).values_list('location__country', flat=True).distinct().order_by('location__country'))
states = list(Park.objects.exclude(
location__state__isnull=True
).exclude(
location__state__exact=''
).values_list('location__state', flat=True).distinct().order_by('location__state'))
# Try to use ModelChoices if available
if HAVE_MODELCHOICES and ModelChoices is not None:
try:
park_types = ModelChoices.get_park_type_choices()
except Exception:
park_types = [
{"value": "THEME_PARK", "label": "Theme Park"},
{"value": "AMUSEMENT_PARK", "label": "Amusement Park"},
{"value": "WATER_PARK", "label": "Water Park"},
]
else:
park_types = [
{"value": "THEME_PARK", "label": "Theme Park"},
{"value": "AMUSEMENT_PARK", "label": "Amusement Park"},
{"value": "WATER_PARK", "label": "Water Park"},
]
return Response({
"park_types": park_types,
"continents": continents,
"countries": countries,
"states": states,
"ordering_options": [
{"value": "name", "label": "Name (A-Z)"},
{"value": "-name", "label": "Name (Z-A)"},
{"value": "opening_date", "label": "Opening Date (Oldest First)"},
{"value": "-opening_date", "label": "Opening Date (Newest First)"},
{"value": "ride_count", "label": "Ride Count (Low to High)"},
{"value": "-ride_count", "label": "Ride Count (High to Low)"},
{"value": "average_rating", "label": "Rating (Low to High)"},
{"value": "-average_rating", "label": "Rating (High to Low)"},
{"value": "roller_coaster_count",
"label": "Coaster Count (Low to High)"},
{"value": "-roller_coaster_count",
"label": "Coaster Count (High to Low)"},
],
})
except Exception:
# Fallback to static options if database query fails
return Response({
"park_types": [
{"value": "THEME_PARK", "label": "Theme Park"},
{"value": "AMUSEMENT_PARK", "label": "Amusement Park"},
{"value": "WATER_PARK", "label": "Water Park"},
],
"continents": [
"North America",
"South America",
"Europe",
"Asia",
"Africa",
"Australia"
],
"countries": [
"United States",
"Canada",
"United Kingdom",
"Germany",
"France",
"Japan"
],
"states": [
"California",
"Florida",
"Ohio",
"Pennsylvania"
],
"ordering_options": [
{"value": "name", "label": "Name (A-Z)"},
{"value": "-name", "label": "Name (Z-A)"},
{"value": "opening_date", "label": "Opening Date (Oldest First)"},
{"value": "-opening_date", "label": "Opening Date (Newest First)"},
{"value": "ride_count", "label": "Ride Count (Low to High)"},
{"value": "-ride_count", "label": "Ride Count (High to Low)"},
],
})
# --- Company search (autocomplete) -----------------------------------------
@@ -352,7 +666,7 @@ class FilterOptionsAPIView(APIView):
summary="Search companies (operators/property owners) for autocomplete",
parameters=[
OpenApiParameter(
name="q", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR
name="q", location=OpenApiParameter.QUERY, type=OpenApiTypes.STR, description="Search query for company names"
)
],
responses={200: OpenApiTypes.OBJECT},
@@ -366,21 +680,41 @@ class CompanySearchAPIView(APIView):
if not q:
return Response([], status=status.HTTP_200_OK)
if ParkCompany is None:
if not MODELS_AVAILABLE or Company is None:
# Provide helpful placeholder structure
return Response(
[
{"id": 1, "name": "Six Flags Entertainment", "slug": "six-flags"},
{"id": 2, "name": "Cedar Fair", "slug": "cedar-fair"},
{"id": 3, "name": "Disney Parks", "slug": "disney"},
]
)
return Response([
{"id": 1, "name": "Six Flags Entertainment", "slug": "six-flags"},
{"id": 2, "name": "Cedar Fair", "slug": "cedar-fair"},
{"id": 3, "name": "Disney Parks", "slug": "disney"},
{"id": 4, "name": "Universal Parks & Resorts", "slug": "universal"},
{"id": 5, "name": "SeaWorld Parks & Entertainment", "slug": "seaworld"},
])
qs = ParkCompany.objects.filter(name__icontains=q)[:20] # type: ignore
results = [
{"id": c.id, "name": c.name, "slug": getattr(c, "slug", "")} for c in qs
]
return Response(results)
try:
# Search companies that can be operators or property owners
qs = Company.objects.filter(
Q(name__icontains=q) &
(Q(roles__contains=['OPERATOR']) | Q(
roles__contains=['PROPERTY_OWNER']))
).distinct()[:20]
results = [
{
"id": c.id,
"name": c.name,
"slug": getattr(c, "slug", ""),
"roles": getattr(c, "roles", [])
}
for c in qs
]
return Response(results)
except Exception:
# Fallback to placeholder data
return Response([
{"id": 1, "name": "Six Flags Entertainment", "slug": "six-flags"},
{"id": 2, "name": "Cedar Fair", "slug": "cedar-fair"},
{"id": 3, "name": "Disney Parks", "slug": "disney"},
])
# --- Search suggestions -----------------------------------------------------

View File

@@ -143,7 +143,7 @@ class ParkPhotoViewSet(ModelViewSet):
raise ValidationError("Park ID is required")
try:
park = Park.objects.get(pk=park_id)
Park.objects.get(pk=park_id)
except Park.DoesNotExist:
raise ValidationError("Park not found")
@@ -199,6 +199,19 @@ class ParkPhotoViewSet(ModelViewSet):
)
try:
# Delete from Cloudflare first if image exists
if instance.image:
try:
from django_cloudflareimages_toolkit.services import CloudflareImagesService
service = CloudflareImagesService()
service.delete_image(instance.image)
logger.info(
f"Successfully deleted park photo from Cloudflare: {instance.image.cloudflare_id}")
except Exception as e:
logger.error(
f"Failed to delete park photo from Cloudflare: {str(e)}")
# Continue with database deletion even if Cloudflare deletion fails
ParkMediaService().delete_photo(
instance.id, deleted_by=cast(UserModel, self.request.user)
)
@@ -377,3 +390,135 @@ class ParkPhotoViewSet(ModelViewSet):
except Exception as e:
logger.error(f"Error in set_primary_photo: {str(e)}", exc_info=True)
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
@extend_schema(
summary="Save Cloudflare image as park photo",
description="Save a Cloudflare image as a park photo after direct upload to Cloudflare",
request=OpenApiTypes.OBJECT,
responses={
201: ParkPhotoOutputSerializer,
400: OpenApiTypes.OBJECT,
401: OpenApiTypes.OBJECT,
404: OpenApiTypes.OBJECT,
},
tags=["Park Media"],
)
@action(detail=False, methods=["post"])
def save_image(self, request, **kwargs):
"""Save a Cloudflare image as a park photo after direct upload to Cloudflare."""
park_pk = self.kwargs.get("park_pk")
if not park_pk:
return Response(
{"error": "Park ID is required"},
status=status.HTTP_400_BAD_REQUEST,
)
try:
park = Park.objects.get(pk=park_pk)
except Park.DoesNotExist:
return Response(
{"error": "Park not found"},
status=status.HTTP_404_NOT_FOUND,
)
cloudflare_image_id = request.data.get("cloudflare_image_id")
if not cloudflare_image_id:
return Response(
{"error": "cloudflare_image_id is required"},
status=status.HTTP_400_BAD_REQUEST,
)
try:
# Import CloudflareImage model and service
from django_cloudflareimages_toolkit.models import CloudflareImage
from django_cloudflareimages_toolkit.services import CloudflareImagesService
from django.utils import timezone
# Always fetch the latest image data from Cloudflare API
try:
# Get image details from Cloudflare API
service = CloudflareImagesService()
image_data = service.get_image(cloudflare_image_id)
if not image_data:
return Response(
{"error": "Image not found in Cloudflare"},
status=status.HTTP_400_BAD_REQUEST,
)
# Try to find existing CloudflareImage record by cloudflare_id
cloudflare_image = None
try:
cloudflare_image = CloudflareImage.objects.get(
cloudflare_id=cloudflare_image_id)
# Update existing record with latest data from Cloudflare
cloudflare_image.status = 'uploaded'
cloudflare_image.uploaded_at = timezone.now()
cloudflare_image.metadata = image_data.get('meta', {})
# Extract variants from nested result structure
cloudflare_image.variants = image_data.get(
'result', {}).get('variants', [])
cloudflare_image.cloudflare_metadata = image_data
cloudflare_image.width = image_data.get('width')
cloudflare_image.height = image_data.get('height')
cloudflare_image.format = image_data.get('format', '')
cloudflare_image.save()
except CloudflareImage.DoesNotExist:
# Create new CloudflareImage record from API response
cloudflare_image = CloudflareImage.objects.create(
cloudflare_id=cloudflare_image_id,
user=request.user,
status='uploaded',
upload_url='', # Not needed for uploaded images
expires_at=timezone.now() + timezone.timedelta(days=365), # Set far future expiry
uploaded_at=timezone.now(),
metadata=image_data.get('meta', {}),
# Extract variants from nested result structure
variants=image_data.get('result', {}).get('variants', []),
cloudflare_metadata=image_data,
width=image_data.get('width'),
height=image_data.get('height'),
format=image_data.get('format', ''),
)
except Exception as api_error:
logger.error(
f"Error fetching image from Cloudflare API: {str(api_error)}", exc_info=True)
return Response(
{"error": f"Failed to fetch image from Cloudflare: {str(api_error)}"},
status=status.HTTP_400_BAD_REQUEST,
)
# Create the park photo with the CloudflareImage reference
photo = ParkPhoto.objects.create(
park=park,
image=cloudflare_image,
uploaded_by=request.user,
caption=request.data.get("caption", ""),
alt_text=request.data.get("alt_text", ""),
photo_type=request.data.get("photo_type", "exterior"),
is_primary=request.data.get("is_primary", False),
is_approved=False, # Default to requiring approval
)
# Handle primary photo logic if requested
if request.data.get("is_primary", False):
try:
ParkMediaService().set_primary_photo(
park_id=park.id, photo_id=photo.id
)
except Exception as e:
logger.error(f"Error setting primary photo: {e}")
# Don't fail the entire operation, just log the error
serializer = ParkPhotoOutputSerializer(photo, context={"request": request})
return Response(serializer.data, status=status.HTTP_201_CREATED)
except Exception as e:
logger.error(f"Error saving park photo: {e}")
return Response(
{"error": f"Failed to save photo: {str(e)}"},
status=status.HTTP_400_BAD_REQUEST,
)

View File

@@ -502,13 +502,13 @@ class RideModelFilterOptionsAPIView(APIView):
.values("id", "name", "slug")
)
categories = (
(
RideModel.objects.exclude(category="")
.values_list("category", flat=True)
.distinct()
)
target_markets = (
(
RideModel.objects.exclude(target_market="")
.values_list("target_market", flat=True)
.distinct()

View File

@@ -204,6 +204,19 @@ class RidePhotoViewSet(ModelViewSet):
)
try:
# Delete from Cloudflare first if image exists
if instance.image:
try:
from django_cloudflareimages_toolkit.services import CloudflareImagesService
service = CloudflareImagesService()
service.delete_image(instance.image)
logger.info(
f"Successfully deleted ride photo from Cloudflare: {instance.image.cloudflare_id}")
except Exception as e:
logger.error(
f"Failed to delete ride photo from Cloudflare: {str(e)}")
# Continue with database deletion even if Cloudflare deletion fails
RideMediaService.delete_photo(
instance, deleted_by=self.request.user # type: ignore
)
@@ -407,3 +420,133 @@ class RidePhotoViewSet(ModelViewSet):
except Exception as e:
logger.error(f"Error in set_primary_photo: {str(e)}", exc_info=True)
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
@extend_schema(
summary="Save Cloudflare image as ride photo",
description="Save a Cloudflare image as a ride photo after direct upload to Cloudflare",
request=OpenApiTypes.OBJECT,
responses={
201: RidePhotoOutputSerializer,
400: OpenApiTypes.OBJECT,
401: OpenApiTypes.OBJECT,
404: OpenApiTypes.OBJECT,
},
tags=["Ride Media"],
)
@action(detail=False, methods=["post"])
def save_image(self, request, **kwargs):
"""Save a Cloudflare image as a ride photo after direct upload to Cloudflare."""
ride_pk = self.kwargs.get("ride_pk")
if not ride_pk:
return Response(
{"error": "Ride ID is required"},
status=status.HTTP_400_BAD_REQUEST,
)
try:
ride = Ride.objects.get(pk=ride_pk)
except Ride.DoesNotExist:
return Response(
{"error": "Ride not found"},
status=status.HTTP_404_NOT_FOUND,
)
cloudflare_image_id = request.data.get("cloudflare_image_id")
if not cloudflare_image_id:
return Response(
{"error": "cloudflare_image_id is required"},
status=status.HTTP_400_BAD_REQUEST,
)
try:
# Import CloudflareImage model and service
from django_cloudflareimages_toolkit.models import CloudflareImage
from django_cloudflareimages_toolkit.services import CloudflareImagesService
from django.utils import timezone
# Always fetch the latest image data from Cloudflare API
try:
# Get image details from Cloudflare API
service = CloudflareImagesService()
image_data = service.get_image(cloudflare_image_id)
if not image_data:
return Response(
{"error": "Image not found in Cloudflare"},
status=status.HTTP_400_BAD_REQUEST,
)
# Try to find existing CloudflareImage record by cloudflare_id
cloudflare_image = None
try:
cloudflare_image = CloudflareImage.objects.get(
cloudflare_id=cloudflare_image_id)
# Update existing record with latest data from Cloudflare
cloudflare_image.status = 'uploaded'
cloudflare_image.uploaded_at = timezone.now()
cloudflare_image.metadata = image_data.get('meta', {})
# Extract variants from nested result structure
cloudflare_image.variants = image_data.get(
'result', {}).get('variants', [])
cloudflare_image.cloudflare_metadata = image_data
cloudflare_image.width = image_data.get('width')
cloudflare_image.height = image_data.get('height')
cloudflare_image.format = image_data.get('format', '')
cloudflare_image.save()
except CloudflareImage.DoesNotExist:
# Create new CloudflareImage record from API response
cloudflare_image = CloudflareImage.objects.create(
cloudflare_id=cloudflare_image_id,
user=request.user,
status='uploaded',
upload_url='', # Not needed for uploaded images
expires_at=timezone.now() + timezone.timedelta(days=365), # Set far future expiry
uploaded_at=timezone.now(),
metadata=image_data.get('meta', {}),
# Extract variants from nested result structure
variants=image_data.get('result', {}).get('variants', []),
cloudflare_metadata=image_data,
width=image_data.get('width'),
height=image_data.get('height'),
format=image_data.get('format', ''),
)
except Exception as api_error:
logger.error(
f"Error fetching image from Cloudflare API: {str(api_error)}", exc_info=True)
return Response(
{"error": f"Failed to fetch image from Cloudflare: {str(api_error)}"},
status=status.HTTP_400_BAD_REQUEST,
)
# Create the ride photo with the CloudflareImage reference
photo = RidePhoto.objects.create(
ride=ride,
image=cloudflare_image,
uploaded_by=request.user,
caption=request.data.get("caption", ""),
alt_text=request.data.get("alt_text", ""),
photo_type=request.data.get("photo_type", "exterior"),
is_primary=request.data.get("is_primary", False),
is_approved=False, # Default to requiring approval
)
# Handle primary photo logic if requested
if request.data.get("is_primary", False):
try:
RideMediaService.set_primary_photo(ride=ride, photo=photo)
except Exception as e:
logger.error(f"Error setting primary photo: {e}")
# Don't fail the entire operation, just log the error
serializer = RidePhotoOutputSerializer(photo, context={"request": request})
return Response(serializer.data, status=status.HTTP_201_CREATED)
except Exception as e:
logger.error(f"Error saving ride photo: {e}")
return Response(
{"error": f"Failed to save photo: {str(e)}"},
status=status.HTTP_400_BAD_REQUEST,
)

View File

@@ -630,11 +630,36 @@ class RideDetailAPIView(APIView):
},
status=status.HTTP_501_NOT_IMPLEMENTED,
)
for key, value in serializer_in.validated_data.items():
validated_data = serializer_in.validated_data
park_change_info = None
# Handle park change specially if park_id is being updated
if 'park_id' in validated_data:
new_park_id = validated_data.pop('park_id')
try:
new_park = Park.objects.get(id=new_park_id) # type: ignore
if new_park.id != ride.park_id:
# Use the move_to_park method for proper handling
park_change_info = ride.move_to_park(new_park)
except Park.DoesNotExist: # type: ignore
raise NotFound("Target park not found")
# Apply other field updates
for key, value in validated_data.items():
setattr(ride, key, value)
ride.save()
# Prepare response data
serializer = RideDetailOutputSerializer(ride, context={"request": request})
return Response(serializer.data)
response_data = serializer.data
# Add park change information to response if applicable
if park_change_info:
response_data['park_change_info'] = park_change_info
return Response(response_data)
def put(self, request: Request, pk: int) -> Response:
# Full replace - reuse patch behavior for simplicity

View File

@@ -903,7 +903,7 @@ class AvatarUploadSerializer(serializers.Serializer):
except serializers.ValidationError:
raise # Re-raise validation errors
except Exception as e:
except Exception:
# PIL validation failed, but let Cloudflare Images try to process it
pass

View File

@@ -375,7 +375,7 @@ class ParkImageSettingsInputSerializer(serializers.Serializer):
from apps.parks.models import ParkPhoto
try:
photo = ParkPhoto.objects.get(id=value)
ParkPhoto.objects.get(id=value)
# The park will be validated in the view
return value
except ParkPhoto.DoesNotExist:
@@ -388,7 +388,7 @@ class ParkImageSettingsInputSerializer(serializers.Serializer):
from apps.parks.models import ParkPhoto
try:
photo = ParkPhoto.objects.get(id=value)
ParkPhoto.objects.get(id=value)
# The park will be validated in the view
return value
except ParkPhoto.DoesNotExist:

View File

@@ -21,7 +21,7 @@ class ReviewUserSerializer(serializers.ModelSerializer):
def get_avatar_url(self, obj):
"""Get the user's avatar URL."""
if hasattr(obj, "profile") and obj.profile:
return obj.profile.get_avatar()
return obj.profile.get_avatar_url()
return "/static/images/default-avatar.png"
def get_display_name(self, obj):

View File

@@ -423,7 +423,7 @@ class RideImageSettingsInputSerializer(serializers.Serializer):
from apps.rides.models import RidePhoto
try:
photo = RidePhoto.objects.get(id=value)
RidePhoto.objects.get(id=value)
# The ride will be validated in the view
return value
except RidePhoto.DoesNotExist:
@@ -436,7 +436,7 @@ class RideImageSettingsInputSerializer(serializers.Serializer):
from apps.rides.models import RidePhoto
try:
photo = RidePhoto.objects.get(id=value)
RidePhoto.objects.get(id=value)
# The ride will be validated in the view
return value
except RidePhoto.DoesNotExist:
@@ -506,6 +506,22 @@ class RideCreateInputSerializer(serializers.Serializer):
"Minimum height cannot be greater than maximum height"
)
# Park area validation when park changes
park_id = attrs.get("park_id")
park_area_id = attrs.get("park_area_id")
if park_id and park_area_id:
try:
from apps.parks.models import ParkArea
park_area = ParkArea.objects.get(id=park_area_id)
if park_area.park_id != park_id:
raise serializers.ValidationError(
f"Park area '{park_area.name}' does not belong to the selected park"
)
except Exception:
# If models aren't available or area doesn't exist, let the view handle it
pass
return attrs

View File

@@ -74,6 +74,8 @@ urlpatterns = [
path("core/", include("apps.api.v1.core.urls")),
path("maps/", include("apps.api.v1.maps.urls")),
path("moderation/", include("apps.moderation.urls")),
# Cloudflare Images Toolkit API endpoints
path("cloudflare-images/", include("django_cloudflareimages_toolkit.urls")),
# Include router URLs (for rankings and any other router-registered endpoints)
path("", include(router.urls)),
]

View File

@@ -54,7 +54,7 @@ class Command(BaseCommand):
)
# Determine which server command to use
server_command = self.get_server_command(options)
self.get_server_command(options)
# Start the server
self.stdout.write("")

View File

@@ -266,13 +266,13 @@ class Command(BaseCommand):
trending_parks = trending_service.get_trending_content(
content_type="parks", limit=3
)
trending_rides = trending_service.get_trending_content(
trending_service.get_trending_content(
content_type="rides", limit=3
)
# Test new content format
new_parks = trending_service.get_new_content(content_type="parks", limit=3)
new_rides = trending_service.get_new_content(content_type="rides", limit=3)
trending_service.get_new_content(content_type="rides", limit=3)
# Verify trending data structure
if trending_parks:

View File

@@ -0,0 +1,138 @@
"""
Request logging middleware for comprehensive request/response logging.
Logs all HTTP requests with detailed data for debugging and monitoring.
"""
import logging
import time
import json
from django.utils.deprecation import MiddlewareMixin
logger = logging.getLogger('request_logging')
class RequestLoggingMiddleware(MiddlewareMixin):
"""
Middleware to log all HTTP requests with method, path, and response code.
Includes detailed request/response data logging for all requests.
"""
# Paths to exclude from detailed logging (e.g., static files, health checks)
EXCLUDE_DETAILED_LOGGING_PATHS = [
'/static/',
'/media/',
'/favicon.ico',
'/health/',
'/admin/jsi18n/',
]
def _should_log_detailed(self, request):
"""Determine if detailed logging should be enabled for this request."""
return not any(
path in request.path for path in self.EXCLUDE_DETAILED_LOGGING_PATHS)
def process_request(self, request):
"""Store request start time and capture request data for detailed logging."""
request._start_time = time.time()
# Enable detailed logging for all requests except excluded paths
should_log_detailed = self._should_log_detailed(request)
request._log_request_data = should_log_detailed
if should_log_detailed:
try:
# Log request data
request_data = {}
if hasattr(request, 'data') and request.data:
request_data = dict(request.data)
elif request.body:
try:
request_data = json.loads(request.body.decode('utf-8'))
except (json.JSONDecodeError, UnicodeDecodeError):
request_data = {'body': str(request.body)[
:200] + '...' if len(str(request.body)) > 200 else str(request.body)}
# Log query parameters
query_params = dict(request.GET) if request.GET else {}
logger.info(f"REQUEST DATA for {request.method} {request.path}:")
if request_data:
logger.info(f" Body: {self._safe_log_data(request_data)}")
if query_params:
logger.info(f" Query: {query_params}")
if hasattr(request, 'user') and request.user.is_authenticated:
logger.info(
f" User: {request.user.username} (ID: {request.user.id})")
except Exception as e:
logger.warning(f"Failed to log request data: {e}")
return None
def process_response(self, request, response):
"""Log request details after response is generated."""
try:
# Calculate request duration
duration = 0
if hasattr(request, '_start_time'):
duration = time.time() - request._start_time
# Basic request logging
logger.info(
f"{request.method} {request.get_full_path()} -> {response.status_code} "
f"({duration:.3f}s)"
)
# Detailed response logging for specific endpoints
if getattr(request, '_log_request_data', False):
try:
# Log response data
if hasattr(response, 'data'):
logger.info(
f"RESPONSE DATA for {request.method} {request.path}:")
logger.info(f" Status: {response.status_code}")
logger.info(f" Data: {self._safe_log_data(response.data)}")
elif hasattr(response, 'content'):
try:
content = json.loads(response.content.decode('utf-8'))
logger.info(
f"RESPONSE DATA for {request.method} {request.path}:")
logger.info(f" Status: {response.status_code}")
logger.info(f" Content: {self._safe_log_data(content)}")
except (json.JSONDecodeError, UnicodeDecodeError):
logger.info(
f"RESPONSE DATA for {request.method} {request.path}:")
logger.info(f" Status: {response.status_code}")
logger.info(f" Content: {str(response.content)[:200]}...")
except Exception as e:
logger.warning(f"Failed to log response data: {e}")
except Exception:
# Don't let logging errors break the request
pass
return response
def _safe_log_data(self, data):
"""Safely log data, truncating if too large and masking sensitive fields."""
try:
# Convert to string representation
if isinstance(data, dict):
# Mask sensitive fields
safe_data = {}
for key, value in data.items():
if any(sensitive in key.lower() for sensitive in ['password', 'token', 'secret', 'key']):
safe_data[key] = '***MASKED***'
else:
safe_data[key] = value
data_str = json.dumps(safe_data, indent=2, default=str)
else:
data_str = json.dumps(data, indent=2, default=str)
# Truncate if too long
if len(data_str) > 1000:
return data_str[:1000] + '...[TRUNCATED]'
return data_str
except Exception:
return str(data)[:500] + '...[ERROR_LOGGING]'

View File

@@ -0,0 +1,5 @@
"""
Patches for third-party packages.
"""
# No patches currently applied

View File

@@ -1,39 +0,0 @@
from django.contrib import admin
from django.contrib.sites.models import Site
from .models import EmailConfiguration
@admin.register(EmailConfiguration)
class EmailConfigurationAdmin(admin.ModelAdmin):
list_display = (
"site",
"from_name",
"from_email",
"reply_to",
"updated_at",
)
list_select_related = ("site",)
search_fields = ("site__domain", "from_name", "from_email", "reply_to")
readonly_fields = ("created_at", "updated_at")
fieldsets = (
(None, {"fields": ("site",)}),
(
"Email Settings",
{
"fields": ("api_key", ("from_name", "from_email"), "reply_to"),
"description": 'Configure the email settings. The From field in emails will appear as "From Name <from@email.com>"',
},
),
(
"Timestamps",
{"fields": ("created_at", "updated_at"), "classes": ("collapse",)},
),
)
def get_queryset(self, request):
return super().get_queryset(request).select_related("site")
def formfield_for_foreignkey(self, db_field, request, **kwargs):
if db_field.name == "site":
kwargs["queryset"] = Site.objects.all().order_by("domain")
return super().formfield_for_foreignkey(db_field, request, **kwargs)

View File

@@ -1,6 +0,0 @@
from django.apps import AppConfig
class EmailServiceConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.email_service"

View File

@@ -1,99 +0,0 @@
from django.core.mail.backends.base import BaseEmailBackend
from django.core.mail.message import sanitize_address
from .services import EmailService
from .models import EmailConfiguration
class ForwardEmailBackend(BaseEmailBackend):
def __init__(self, fail_silently=False, **kwargs):
super().__init__(fail_silently=fail_silently)
self.site = kwargs.get("site", None)
def send_messages(self, email_messages):
"""
Send one or more EmailMessage objects and return the number of email
messages sent.
"""
if not email_messages:
return 0
num_sent = 0
for message in email_messages:
try:
sent = self._send(message)
if sent:
num_sent += 1
except Exception:
if not self.fail_silently:
raise
return num_sent
def _send(self, email_message):
"""Send an EmailMessage object."""
if not email_message.recipients():
return False
# Get the first recipient (ForwardEmail API sends to one recipient at a
# time)
to_email = email_message.to[0]
# Get site from connection or instance
if hasattr(email_message, "connection") and hasattr(
email_message.connection, "site"
):
site = email_message.connection.site
else:
site = self.site
if not site:
raise ValueError("Either request or site must be provided")
# Get the site's email configuration
try:
config = EmailConfiguration.objects.get(site=site)
except EmailConfiguration.DoesNotExist:
raise ValueError(f"Email configuration not found for site: {site.domain}")
# Get the from email, falling back to site's default if not provided
if email_message.from_email:
from_email = sanitize_address(
email_message.from_email, email_message.encoding
)
else:
from_email = config.default_from_email
# Extract clean email address
from_email = EmailService.extract_email(from_email)
# Get reply-to from message headers or use default
reply_to = None
if hasattr(email_message, "reply_to") and email_message.reply_to:
reply_to = email_message.reply_to[0]
elif (
hasattr(email_message, "extra_headers")
and "Reply-To" in email_message.extra_headers
):
reply_to = email_message.extra_headers["Reply-To"]
# Get message content
if email_message.content_subtype == "html":
# If it's HTML content, we'll send it as text for now
# You could extend this to support HTML emails if needed
text = email_message.body
else:
text = email_message.body
try:
EmailService.send_email(
to=to_email,
subject=email_message.subject,
text=text,
from_email=from_email,
reply_to=reply_to,
site=site,
)
return True
except Exception:
if not self.fail_silently:
raise
return False

View File

@@ -1,184 +0,0 @@
from django.core.management.base import BaseCommand
from django.contrib.auth import get_user_model
from django.contrib.sites.models import Site
from django.test import RequestFactory, Client
from allauth.account.models import EmailAddress
from apps.accounts.adapters import CustomAccountAdapter
from django.conf import settings
import uuid
User = get_user_model()
class Command(BaseCommand):
help = "Test all email flows in the application"
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.factory = RequestFactory()
# Disable CSRF for testing
self.client = Client(enforce_csrf_checks=False)
self.adapter = CustomAccountAdapter()
self.site = Site.objects.get_current()
# Generate unique test data
unique_id = str(uuid.uuid4())[:8]
self.test_username = f"testuser_{unique_id}"
self.test_email = f"test_{unique_id}@thrillwiki.com"
self.test_password = "[PASSWORD-REMOVED]"
self.new_password = "[PASSWORD-REMOVED]"
# Add testserver to ALLOWED_HOSTS
if "testserver" not in settings.ALLOWED_HOSTS:
settings.ALLOWED_HOSTS.append("testserver")
def handle(self, *args, **options):
self.stdout.write("Starting email flow tests...\n")
# Clean up any existing test users
User.objects.filter(email__endswith="@thrillwiki.com").delete()
# Test registration email
self.test_registration()
# Create a test user for other flows
user = User.objects.create_user(
username=f"testuser2_{str(uuid.uuid4())[:8]}",
email=f"test2_{str(uuid.uuid4())[:8]}@thrillwiki.com",
password=self.test_password,
)
EmailAddress.objects.create(
user=user, email=user.email, primary=True, verified=True
)
# Log in the test user
self.client.force_login(user)
# Test other flows
self.test_password_change(user)
self.test_email_change(user)
self.test_password_reset(user)
# Cleanup
User.objects.filter(email__endswith="@thrillwiki.com").delete()
self.stdout.write(self.style.SUCCESS("All email flow tests completed!\n"))
def test_registration(self):
"""Test registration email flow"""
self.stdout.write("Testing registration email...")
try:
# Use dj-rest-auth registration endpoint
response = self.client.post(
"/api/auth/registration/",
{
"username": self.test_username,
"email": self.test_email,
"password1": self.test_password,
"password2": self.test_password,
},
content_type="application/json",
)
if response.status_code in [200, 201, 204]:
self.stdout.write(
self.style.SUCCESS("Registration email test passed!\n")
)
else:
self.stdout.write(
self.style.WARNING(
f"Registration returned status {response.status_code}: {
response.content.decode()
}\n"
)
)
except Exception as e:
self.stdout.write(
self.style.ERROR(f"Registration email test failed: {str(e)}\n")
)
def test_password_change(self, user):
"""Test password change using dj-rest-auth"""
self.stdout.write("Testing password change email...")
try:
response = self.client.post(
"/api/auth/password/change/",
{
"old_password": self.test_password,
"new_password1": self.new_password,
"new_password2": self.new_password,
},
content_type="application/json",
)
if response.status_code == 200:
self.stdout.write(
self.style.SUCCESS("Password change email test passed!\n")
)
else:
self.stdout.write(
self.style.WARNING(
f"Password change returned status {response.status_code}: {
response.content.decode()
}\n"
)
)
except Exception as e:
self.stdout.write(
self.style.ERROR(f"Password change email test failed: {str(e)}\n")
)
def test_email_change(self, user):
"""Test email change verification"""
self.stdout.write("Testing email change verification...")
try:
new_email = f"newemail_{str(uuid.uuid4())[:8]}@thrillwiki.com"
response = self.client.post(
"/api/auth/email/",
{"email": new_email},
content_type="application/json",
)
if response.status_code == 200:
self.stdout.write(
self.style.SUCCESS("Email change verification test passed!\n")
)
else:
self.stdout.write(
self.style.WARNING(
f"Email change returned status {response.status_code}: {
response.content.decode()
}\n"
)
)
except Exception as e:
self.stdout.write(
self.style.ERROR(f"Email change verification test failed: {str(e)}\n")
)
def test_password_reset(self, user):
"""Test password reset using dj-rest-auth"""
self.stdout.write("Testing password reset email...")
try:
# Request password reset
response = self.client.post(
"/api/auth/password/reset/",
{"email": user.email},
content_type="application/json",
)
if response.status_code == 200:
self.stdout.write(
self.style.SUCCESS("Password reset email test passed!\n")
)
else:
self.stdout.write(
self.style.WARNING(
f"Password reset returned status {response.status_code}: {
response.content.decode()
}\n"
)
)
except Exception as e:
self.stdout.write(
self.style.ERROR(f"Password reset email test failed: {str(e)}\n")
)

View File

@@ -1,229 +0,0 @@
from django.core.management.base import BaseCommand
from django.contrib.sites.models import Site
from django.core.mail import send_mail
from django.conf import settings
import requests
import os
from apps.email_service.models import EmailConfiguration
from apps.email_service.services import EmailService
from apps.email_service.backends import ForwardEmailBackend
class Command(BaseCommand):
help = "Test the email service functionality"
def add_arguments(self, parser):
parser.add_argument(
"--to",
type=str,
help="Recipient email address (optional, defaults to current user's email)",
)
parser.add_argument(
"--api-key",
type=str,
help="ForwardEmail API key (optional, will use configured value)",
)
parser.add_argument(
"--from-email",
type=str,
help="Sender email address (optional, will use configured value)",
)
def get_config(self):
"""Get email configuration from database or environment"""
try:
site = Site.objects.get(id=settings.SITE_ID)
config = EmailConfiguration.objects.get(site=site)
return {
"api_key": config.api_key,
"from_email": config.default_from_email,
"site": site,
}
except (Site.DoesNotExist, EmailConfiguration.DoesNotExist):
# Try environment variables
api_key = os.environ.get("FORWARD_EMAIL_API_KEY")
from_email = os.environ.get("FORWARD_EMAIL_FROM")
if not api_key or not from_email:
self.stdout.write(
self.style.WARNING(
"No configuration found in database or environment variables.\n"
"Please either:\n"
"1. Configure email settings in Django admin, or\n"
"2. Set environment variables FORWARD_EMAIL_API_KEY and FORWARD_EMAIL_FROM, or\n"
"3. Provide --api-key and --from-email arguments"
)
)
return None
return {
"api_key": api_key,
"from_email": from_email,
"site": Site.objects.get(id=settings.SITE_ID),
}
def handle(self, *args, **options):
self.stdout.write(self.style.SUCCESS("Starting email service tests..."))
# Get configuration
config = self.get_config()
if not config and not (options["api_key"] and options["from_email"]):
self.stdout.write(
self.style.ERROR("No email configuration available. Tests aborted.")
)
return
# Use provided values or fall back to config
api_key = options["api_key"] or config["api_key"]
from_email = options["from_email"] or config["from_email"]
site = config["site"]
# If no recipient specified, use the from_email address for testing
to_email = options["to"] or "test@thrillwiki.com"
self.stdout.write(self.style.SUCCESS("Using configuration:"))
self.stdout.write(f" From: {from_email}")
self.stdout.write(f" To: {to_email}")
self.stdout.write(f" API Key: {'*' * len(api_key)}")
self.stdout.write(f" Site: {site.domain}")
try:
# 1. Test site configuration
config = self.test_site_configuration(api_key, from_email)
# 2. Test direct service
self.test_email_service_directly(to_email, config.site)
# 3. Test API endpoint
self.test_api_endpoint(to_email)
# 4. Test Django email backend
self.test_email_backend(to_email, config.site)
self.stdout.write(
self.style.SUCCESS("\nAll tests completed successfully! 🎉")
)
except Exception as e:
self.stdout.write(self.style.ERROR(f"\nTests failed: {str(e)}"))
def test_site_configuration(self, api_key, from_email):
"""Test creating and retrieving site configuration"""
self.stdout.write("\nTesting site configuration...")
try:
# Get or create default site
site = Site.objects.get_or_create(
id=settings.SITE_ID,
defaults={"domain": "example.com", "name": "example.com"},
)[0]
# Create or update email configuration
config, created = EmailConfiguration.objects.update_or_create(
site=site,
defaults={
"api_key": api_key,
"default_from_email": from_email,
},
)
action = "Created new" if created else "Updated existing"
self.stdout.write(self.style.SUCCESS(f"{action} site configuration"))
return config
except Exception as e:
self.stdout.write(
self.style.ERROR(f"✗ Site configuration failed: {str(e)}")
)
raise
def test_api_endpoint(self, to_email):
"""Test sending email via the API endpoint"""
self.stdout.write("\nTesting API endpoint...")
try:
# Make request to the API endpoint
response = requests.post(
"http://127.0.0.1:8000/api/email/send-email/",
json={
"to": to_email,
"subject": "Test Email via API",
"text": "This is a test email sent via the API endpoint.",
},
headers={
"Content-Type": "application/json",
},
timeout=60,
)
if response.status_code == 200:
self.stdout.write(self.style.SUCCESS("✓ API endpoint test successful"))
else:
self.stdout.write(
self.style.ERROR(
f"✗ API endpoint test failed with status {
response.status_code
}: {response.text}"
)
)
raise Exception(f"API test failed: {response.text}")
except requests.exceptions.ConnectionError:
self.stdout.write(
self.style.ERROR(
"✗ API endpoint test failed: Could not connect to server. "
"Make sure the Django development server is running."
)
)
raise Exception("Could not connect to Django server")
except Exception as e:
self.stdout.write(self.style.ERROR(f"✗ API endpoint test failed: {str(e)}"))
raise
def test_email_backend(self, to_email, site):
"""Test sending email via Django's email backend"""
self.stdout.write("\nTesting Django email backend...")
try:
# Create a connection with site context
backend = ForwardEmailBackend(fail_silently=False, site=site)
# Debug output
self.stdout.write(
f" Debug: Using from_email: {site.email_config.default_from_email}"
)
self.stdout.write(f" Debug: Using to_email: {to_email}")
send_mail(
subject="Test Email via Backend",
message="This is a test email sent via the Django email backend.",
from_email=site.email_config.default_from_email, # Explicitly set from_email
recipient_list=[to_email],
fail_silently=False,
connection=backend,
)
self.stdout.write(self.style.SUCCESS("✓ Email backend test successful"))
except Exception as e:
self.stdout.write(
self.style.ERROR(f"✗ Email backend test failed: {str(e)}")
)
raise
def test_email_service_directly(self, to_email, site):
"""Test sending email directly via EmailService"""
self.stdout.write("\nTesting EmailService directly...")
try:
response = EmailService.send_email(
to=to_email,
subject="Test Email via Service",
text="This is a test email sent directly via the EmailService.",
site=site,
)
self.stdout.write(
self.style.SUCCESS("✓ Direct EmailService test successful")
)
return response
except Exception as e:
self.stdout.write(
self.style.ERROR(f"✗ Direct EmailService test failed: {str(e)}")
)
raise

View File

@@ -1,140 +0,0 @@
# Generated by Django 5.1.4 on 2025-08-13 21:35
import django.db.models.deletion
import pgtrigger.compiler
import pgtrigger.migrations
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("pghistory", "0006_delete_aggregateevent"),
("sites", "0002_alter_domain_unique"),
]
operations = [
migrations.CreateModel(
name="EmailConfiguration",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("api_key", models.CharField(max_length=255)),
("from_email", models.EmailField(max_length=254)),
(
"from_name",
models.CharField(
help_text="The name that will appear in the From field of emails",
max_length=255,
),
),
("reply_to", models.EmailField(max_length=254)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"site",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="sites.site",
),
),
],
options={
"verbose_name": "Email Configuration",
"verbose_name_plural": "Email Configurations",
},
),
migrations.CreateModel(
name="EmailConfigurationEvent",
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()),
("api_key", models.CharField(max_length=255)),
("from_email", models.EmailField(max_length=254)),
(
"from_name",
models.CharField(
help_text="The name that will appear in the From field of emails",
max_length=255,
),
),
("reply_to", models.EmailField(max_length=254)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=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="email_service.emailconfiguration",
),
),
(
"site",
models.ForeignKey(
db_constraint=False,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="+",
related_query_name="+",
to="sites.site",
),
),
],
options={
"abstract": False,
},
),
pgtrigger.migrations.AddTrigger(
model_name="emailconfiguration",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "email_service_emailconfigurationevent" ("api_key", "created_at", "from_email", "from_name", "id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "reply_to", "site_id", "updated_at") VALUES (NEW."api_key", NEW."created_at", NEW."from_email", NEW."from_name", NEW."id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."reply_to", NEW."site_id", NEW."updated_at"); RETURN NULL;',
hash="[AWS-SECRET-REMOVED]",
operation="INSERT",
pgid="pgtrigger_insert_insert_08c59",
table="email_service_emailconfiguration",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="emailconfiguration",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "email_service_emailconfigurationevent" ("api_key", "created_at", "from_email", "from_name", "id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "reply_to", "site_id", "updated_at") VALUES (NEW."api_key", NEW."created_at", NEW."from_email", NEW."from_name", NEW."id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."reply_to", NEW."site_id", NEW."updated_at"); RETURN NULL;',
hash="[AWS-SECRET-REMOVED]",
operation="UPDATE",
pgid="pgtrigger_update_update_992a4",
table="email_service_emailconfiguration",
when="AFTER",
),
),
),
]

View File

@@ -1,51 +0,0 @@
# Generated by Django 5.2.5 on 2025-08-24 18:23
import pgtrigger.compiler
import pgtrigger.migrations
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("email_service", "0001_initial"),
]
operations = [
pgtrigger.migrations.RemoveTrigger(
model_name="emailconfiguration",
name="insert_insert",
),
pgtrigger.migrations.RemoveTrigger(
model_name="emailconfiguration",
name="update_update",
),
pgtrigger.migrations.AddTrigger(
model_name="emailconfiguration",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "email_service_emailconfigurationevent" ("api_key", "created_at", "from_email", "from_name", "id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "reply_to", "site_id", "updated_at") VALUES (NEW."api_key", NEW."created_at", NEW."from_email", NEW."from_name", NEW."id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."reply_to", NEW."site_id", NEW."updated_at"); RETURN NULL;',
hash="f19f3c7f7d904d5f850a2ff1e0bf1312e855c8c0",
operation="INSERT",
pgid="pgtrigger_insert_insert_08c59",
table="email_service_emailconfiguration",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="emailconfiguration",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "email_service_emailconfigurationevent" ("api_key", "created_at", "from_email", "from_name", "id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "reply_to", "site_id", "updated_at") VALUES (NEW."api_key", NEW."created_at", NEW."from_email", NEW."from_name", NEW."id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."reply_to", NEW."site_id", NEW."updated_at"); RETURN NULL;',
hash="e445521baf2cfb51379b2a6be550b4a638d60202",
operation="UPDATE",
pgid="pgtrigger_update_update_992a4",
table="email_service_emailconfiguration",
when="AFTER",
),
),
),
]

View File

@@ -1,25 +0,0 @@
from django.db import models
from django.contrib.sites.models import Site
from apps.core.history import TrackedModel
import pghistory
@pghistory.track()
class EmailConfiguration(TrackedModel):
api_key = models.CharField(max_length=255)
from_email = models.EmailField()
from_name = models.CharField(
max_length=255,
help_text="The name that will appear in the From field of emails",
)
reply_to = models.EmailField()
site = models.ForeignKey(Site, on_delete=models.CASCADE)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return f"{self.from_name} <{self.from_email}>"
class Meta:
verbose_name = "Email Configuration"
verbose_name_plural = "Email Configurations"

View File

@@ -1,111 +0,0 @@
import requests
from django.conf import settings
from django.contrib.sites.shortcuts import get_current_site
from django.core.exceptions import ImproperlyConfigured
from django.core.mail.message import sanitize_address
from .models import EmailConfiguration
import json
import base64
class EmailService:
@staticmethod
def send_email(
*,
to: str,
subject: str,
text: str,
from_email: str = None,
html: str = None,
reply_to: str = None,
request=None,
site=None,
):
# Get the site configuration
if site is None and request is not None:
site = get_current_site(request)
elif site is None:
raise ImproperlyConfigured("Either request or site must be provided")
try:
# Fetch the email configuration for the current site
email_config = EmailConfiguration.objects.get(site=site)
api_key = email_config.api_key
# Use provided from_email or construct from config
if not from_email:
from_email = f"{email_config.from_name} <{email_config.from_email}>"
elif "<" not in from_email:
# If from_email is provided but doesn't include a name, add the
# configured name
from_email = f"{email_config.from_name} <{from_email}>"
# Use provided reply_to or fall back to config
if not reply_to:
reply_to = email_config.reply_to
except EmailConfiguration.DoesNotExist:
raise ImproperlyConfigured(
f"Email configuration is missing for site: {site.domain}"
)
# Ensure the reply_to address is clean
reply_to = sanitize_address(reply_to, "utf-8")
# Format data for the API
data = {
"from": from_email, # Now includes the name in format "Name <email@domain.com>"
"to": to,
"subject": subject,
"text": text,
"replyTo": reply_to,
}
# Add HTML version if provided
if html:
data["html"] = html
# Debug output
print("\nEmail Service Debug:")
print(f"From: {from_email}")
print(f"To: {to}")
print(f"Reply-To: {reply_to}")
print(f"API Key: {api_key}")
print(f"Site: {site.domain}")
print(f"Request URL: {settings.FORWARD_EMAIL_BASE_URL}/v1/emails")
print(f"Request Data: {json.dumps(data, indent=2)}")
# Create Basic auth header with API key as username and empty password
auth_header = base64.b64encode(f"{api_key}:".encode()).decode()
headers = {
"Authorization": f"Basic {auth_header}",
"Accept": "application/json",
"Content-Type": "application/json",
}
try:
response = requests.post(
f"{settings.FORWARD_EMAIL_BASE_URL}/v1/emails",
json=data,
headers=headers,
timeout=60,
)
# Debug output
print(f"Response Status: {response.status_code}")
print(f"Response Headers: {dict(response.headers)}")
print(f"Response Body: {response.text}")
if response.status_code != 200:
error_message = response.text if response.text else "Unknown error"
raise Exception(
f"Failed to send email (Status {response.status_code}): {
error_message
}"
)
return response.json()
except requests.RequestException as e:
raise Exception(f"Failed to send email: {str(e)}")
except Exception as e:
raise Exception(f"Failed to send email: {str(e)}")

View File

@@ -1 +0,0 @@
# Create your tests here.

View File

@@ -1,6 +0,0 @@
from django.urls import path
from .views import SendEmailView
urlpatterns = [
path("send-email/", SendEmailView.as_view(), name="send-email"),
]

View File

@@ -1,49 +0,0 @@
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status
from rest_framework.permissions import AllowAny
from django.contrib.sites.shortcuts import get_current_site
from .services import EmailService
class SendEmailView(APIView):
permission_classes = [AllowAny] # Allow unauthenticated access
def post(self, request):
data = request.data
to = data.get("to")
subject = data.get("subject")
text = data.get("text")
from_email = data.get("from_email") # Optional
if not all([to, subject, text]):
return Response(
{
"error": "Missing required fields",
"required_fields": ["to", "subject", "text"],
},
status=status.HTTP_400_BAD_REQUEST,
)
try:
# Get the current site
site = get_current_site(request)
# Send email using the site's configuration
response = EmailService.send_email(
to=to,
subject=subject,
text=text,
from_email=from_email, # Will use site's default if None
site=site,
)
return Response(
{"message": "Email sent successfully", "response": response},
status=status.HTTP_200_OK,
)
except Exception as e:
return Response(
{"error": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR
)

View File

@@ -0,0 +1,75 @@
# 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"),
("moderation", "0004_alter_moderationqueue_options_and_more"),
]
operations = [
pgtrigger.migrations.RemoveTrigger(
model_name="photosubmission",
name="insert_insert",
),
pgtrigger.migrations.RemoveTrigger(
model_name="photosubmission",
name="update_update",
),
migrations.AlterField(
model_name="photosubmission",
name="photo",
field=models.ForeignKey(
help_text="Photo submission stored on Cloudflare Images",
on_delete=django.db.models.deletion.CASCADE,
to="django_cloudflareimages_toolkit.cloudflareimage",
),
),
migrations.AlterField(
model_name="photosubmissionevent",
name="photo",
field=models.ForeignKey(
db_constraint=False,
help_text="Photo submission 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="photosubmission",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "moderation_photosubmissionevent" ("caption", "content_type_id", "created_at", "date_taken", "handled_at", "handled_by_id", "id", "notes", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "photo_id", "status", "updated_at", "user_id") VALUES (NEW."caption", NEW."content_type_id", NEW."created_at", NEW."date_taken", NEW."handled_at", NEW."handled_by_id", NEW."id", NEW."notes", NEW."object_id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."photo_id", NEW."status", NEW."updated_at", NEW."user_id"); RETURN NULL;',
hash="198c1500ffe6dd50f9fc4bc7bbfbc1c392f1faa6",
operation="INSERT",
pgid="pgtrigger_insert_insert_62865",
table="moderation_photosubmission",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="photosubmission",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "moderation_photosubmissionevent" ("caption", "content_type_id", "created_at", "date_taken", "handled_at", "handled_by_id", "id", "notes", "object_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "photo_id", "status", "updated_at", "user_id") VALUES (NEW."caption", NEW."content_type_id", NEW."created_at", NEW."date_taken", NEW."handled_at", NEW."handled_by_id", NEW."id", NEW."notes", NEW."object_id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."photo_id", NEW."status", NEW."updated_at", NEW."user_id"); RETURN NULL;',
hash="4757ec44aa21ca0567f894df0c2a5db7d39ec98f",
operation="UPDATE",
pgid="pgtrigger_update_update_9c311",
table="moderation_photosubmission",
when="AFTER",
),
),
),
]

View File

@@ -646,7 +646,11 @@ class PhotoSubmission(TrackedModel):
content_object = GenericForeignKey("content_type", "object_id")
# The photo itself
photo = models.ImageField(upload_to="submissions/photos/")
photo = models.ForeignKey(
'django_cloudflareimages_toolkit.CloudflareImage',
on_delete=models.CASCADE,
help_text="Photo submission stored on Cloudflare Images"
)
caption = models.CharField(max_length=255, blank=True)
date_taken = models.DateField(null=True, blank=True)

View File

@@ -7,7 +7,6 @@ from typing import Optional, Dict, Any, Union
from django.db import transaction
from django.utils import timezone
from django.db.models import QuerySet
from django.contrib.contenttypes.models import ContentType
from apps.accounts.models import User
from .models import EditSubmission, PhotoSubmission, ModerationQueue

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 = [
("parks", "0008_parkphoto_parkphotoevent_and_more"),
]
operations = [
migrations.AlterField(
model_name="parkphoto",
name="image",
field=cloudflare_images.field.CloudflareImagesField(
help_text="Park photo stored on Cloudflare Images",
upload_to="",
variant="public",
),
),
migrations.AlterField(
model_name="parkphotoevent",
name="image",
field=cloudflare_images.field.CloudflareImagesField(
help_text="Park 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 = [
("parks", "0009_cloudflare_images_integration"),
("parks", "0008_parkphoto_parkphotoevent_and_more"),
]
operations = [

View File

@@ -0,0 +1,75 @@
# Generated by Django 5.2.5 on 2025-08-30 21:42
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"),
("parks", "0011_remove_park_insert_insert_remove_park_update_update_and_more"),
]
operations = [
pgtrigger.migrations.RemoveTrigger(
model_name="parkphoto",
name="insert_insert",
),
pgtrigger.migrations.RemoveTrigger(
model_name="parkphoto",
name="update_update",
),
migrations.AlterField(
model_name="parkphoto",
name="image",
field=models.ForeignKey(
help_text="Park photo stored on Cloudflare Images",
on_delete=django.db.models.deletion.CASCADE,
to="django_cloudflareimages_toolkit.cloudflareimage",
),
),
migrations.AlterField(
model_name="parkphotoevent",
name="image",
field=models.ForeignKey(
db_constraint=False,
help_text="Park 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="parkphoto",
trigger=pgtrigger.compiler.Trigger(
name="insert_insert",
sql=pgtrigger.compiler.UpsertTriggerSql(
func='INSERT INTO "parks_parkphotoevent" ("alt_text", "caption", "created_at", "date_taken", "id", "image_id", "is_approved", "is_primary", "park_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_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", NEW."park_id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."updated_at", NEW."uploaded_by_id"); RETURN NULL;',
hash="403652164d3e615dae5a14052a56db2851c5cf05",
operation="INSERT",
pgid="pgtrigger_insert_insert_e2033",
table="parks_parkphoto",
when="AFTER",
),
),
),
pgtrigger.migrations.AddTrigger(
model_name="parkphoto",
trigger=pgtrigger.compiler.Trigger(
name="update_update",
sql=pgtrigger.compiler.UpsertTriggerSql(
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
func='INSERT INTO "parks_parkphotoevent" ("alt_text", "caption", "created_at", "date_taken", "id", "image_id", "is_approved", "is_primary", "park_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_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", NEW."park_id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."updated_at", NEW."uploaded_by_id"); RETURN NULL;',
hash="29c60ad09c570b8c03ad6c17052a8f9874314895",
operation="UPDATE",
pgid="pgtrigger_update_update_42711",
table="parks_parkphoto",
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
@@ -34,8 +33,10 @@ class ParkPhoto(TrackedModel):
"parks.Park", on_delete=models.CASCADE, related_name="photos"
)
image = CloudflareImagesField(
variant="public", help_text="Park photo stored on Cloudflare Images"
image = models.ForeignKey(
'django_cloudflareimages_toolkit.CloudflareImage',
on_delete=models.CASCADE,
help_text="Park photo stored on Cloudflare Images"
)
caption = models.CharField(max_length=255, blank=True)

View File

@@ -2,7 +2,6 @@ from .querysets import get_base_park_queryset
from apps.core.mixins import HTMXFilterableMixin
from .models.location import ParkLocation
from .models.media import ParkPhoto
from apps.moderation.models import EditSubmission
from apps.moderation.services import ModerationService
from apps.moderation.mixins import (
EditSubmissionMixin,

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