mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-22 04:11:09 -05:00
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:
@@ -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 = [
|
||||
|
||||
@@ -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(
|
||||
58
backend/apps/accounts/migrations/0010_auto_20250830_1657.py
Normal file
58
backend/apps/accounts/migrations/0010_auto_20250830_1657.py
Normal 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,
|
||||
),
|
||||
]
|
||||
@@ -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;"
|
||||
),
|
||||
]
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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__)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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]:
|
||||
|
||||
@@ -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',
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 -----------------------------------------------------
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)),
|
||||
]
|
||||
|
||||
@@ -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("")
|
||||
|
||||
@@ -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:
|
||||
|
||||
138
backend/apps/core/middleware/request_logging.py
Normal file
138
backend/apps/core/middleware/request_logging.py
Normal 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]'
|
||||
5
backend/apps/core/patches/__init__.py
Normal file
5
backend/apps/core/patches/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""
|
||||
Patches for third-party packages.
|
||||
"""
|
||||
|
||||
# No patches currently applied
|
||||
@@ -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)
|
||||
@@ -1,6 +0,0 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class EmailServiceConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.email_service"
|
||||
@@ -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
|
||||
@@ -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")
|
||||
)
|
||||
@@ -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
|
||||
@@ -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",
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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",
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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"
|
||||
@@ -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)}")
|
||||
@@ -1 +0,0 @@
|
||||
# Create your tests here.
|
||||
@@ -1,6 +0,0 @@
|
||||
from django.urls import path
|
||||
from .views import SendEmailView
|
||||
|
||||
urlpatterns = [
|
||||
path("send-email/", SendEmailView.as_view(), name="send-email"),
|
||||
]
|
||||
@@ -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
|
||||
)
|
||||
@@ -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",
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user