mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2026-01-01 22:07:03 -05:00
feat: Implement initial schema and add various API, service, and management command enhancements across the application.
This commit is contained in:
@@ -23,6 +23,7 @@ class UserProfileUpdateInputSerializer(serializers.ModelSerializer):
|
||||
cloudflare_id = validated_data.pop("cloudflare_image_id", None)
|
||||
if cloudflare_id:
|
||||
from django_cloudflareimages_toolkit.models import CloudflareImage
|
||||
|
||||
image, _ = CloudflareImage.objects.get_or_create(cloudflare_id=cloudflare_id)
|
||||
instance.avatar = image
|
||||
|
||||
|
||||
@@ -76,9 +76,7 @@ urlpatterns = [
|
||||
name="update_privacy_settings",
|
||||
),
|
||||
# Security settings endpoints
|
||||
path(
|
||||
"settings/security/", views.get_security_settings, name="get_security_settings"
|
||||
),
|
||||
path("settings/security/", views.get_security_settings, name="get_security_settings"),
|
||||
path(
|
||||
"settings/security/update/",
|
||||
views.update_security_settings,
|
||||
@@ -90,9 +88,7 @@ urlpatterns = [
|
||||
path("top-lists/", views.get_user_top_lists, name="get_user_top_lists"),
|
||||
path("top-lists/create/", views.create_top_list, name="create_top_list"),
|
||||
path("top-lists/<int:list_id>/", views.update_top_list, name="update_top_list"),
|
||||
path(
|
||||
"top-lists/<int:list_id>/delete/", views.delete_top_list, name="delete_top_list"
|
||||
),
|
||||
path("top-lists/<int:list_id>/delete/", views.delete_top_list, name="delete_top_list"),
|
||||
# Notification endpoints
|
||||
path("notifications/", views.get_user_notifications, name="get_user_notifications"),
|
||||
path(
|
||||
@@ -114,18 +110,13 @@ urlpatterns = [
|
||||
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"),
|
||||
|
||||
# Login history endpoint
|
||||
path("login-history/", views.get_login_history, name="get_login_history"),
|
||||
|
||||
# Magic Link (Login by Code) endpoints
|
||||
path("magic-link/request/", views_magic_link.request_magic_link, name="request_magic_link"),
|
||||
path("magic-link/verify/", views_magic_link.verify_magic_link, name="verify_magic_link"),
|
||||
|
||||
# Public Profile
|
||||
path("profiles/<str:username>/", views.get_public_user_profile, name="get_public_user_profile"),
|
||||
|
||||
# ViewSet routes
|
||||
path("", include(router.urls)),
|
||||
]
|
||||
|
||||
|
||||
@@ -69,8 +69,7 @@ logger = logging.getLogger(__name__)
|
||||
200: {
|
||||
"description": "User successfully deleted with submissions preserved",
|
||||
"example": {
|
||||
"success": True,
|
||||
"message": "User successfully deleted with submissions preserved",
|
||||
"detail": "User successfully deleted with submissions preserved",
|
||||
"deleted_user": {
|
||||
"username": "john_doe",
|
||||
"user_id": "1234",
|
||||
@@ -92,17 +91,16 @@ logger = logging.getLogger(__name__)
|
||||
400: {
|
||||
"description": "Bad request - user cannot be deleted",
|
||||
"example": {
|
||||
"success": False,
|
||||
"error": "Cannot delete user: Cannot delete superuser accounts",
|
||||
"detail": "Cannot delete user: Cannot delete superuser accounts",
|
||||
},
|
||||
},
|
||||
404: {
|
||||
"description": "User not found",
|
||||
"example": {"success": False, "error": "User not found"},
|
||||
"example": {"detail": "User not found"},
|
||||
},
|
||||
403: {
|
||||
"description": "Permission denied - admin access required",
|
||||
"example": {"success": False, "error": "Admin access required"},
|
||||
"example": {"detail": "Admin access required"},
|
||||
},
|
||||
},
|
||||
tags=["User Management"],
|
||||
@@ -137,7 +135,7 @@ def delete_user_preserve_submissions(request, user_id):
|
||||
"is_superuser": user.is_superuser,
|
||||
"user_role": user.role,
|
||||
"rejection_reason": reason,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
# Determine error code based on reason
|
||||
@@ -151,8 +149,7 @@ def delete_user_preserve_submissions(request, user_id):
|
||||
|
||||
return Response(
|
||||
{
|
||||
"success": False,
|
||||
"error": f"Cannot delete user: {reason}",
|
||||
"detail": f"Cannot delete user: {reason}",
|
||||
"error_code": error_code,
|
||||
"user_info": {
|
||||
"username": user.username,
|
||||
@@ -174,7 +171,7 @@ def delete_user_preserve_submissions(request, user_id):
|
||||
"target_user": user.username,
|
||||
"target_user_id": user_id,
|
||||
"action": "user_deletion",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
# Perform the deletion
|
||||
@@ -185,17 +182,16 @@ def delete_user_preserve_submissions(request, user_id):
|
||||
f"Successfully deleted user {result['deleted_user']['username']} (ID: {user_id}) by admin {request.user.username}",
|
||||
extra={
|
||||
"admin_user": request.user.username,
|
||||
"deleted_user": result['deleted_user']['username'],
|
||||
"deleted_user": result["deleted_user"]["username"],
|
||||
"deleted_user_id": user_id,
|
||||
"preserved_submissions": result['preserved_submissions'],
|
||||
"preserved_submissions": result["preserved_submissions"],
|
||||
"action": "user_deletion_completed",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
return Response(
|
||||
{
|
||||
"success": True,
|
||||
"message": "User successfully deleted with submissions preserved",
|
||||
"detail": "User successfully deleted with submissions preserved",
|
||||
**result,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
@@ -208,16 +204,15 @@ def delete_user_preserve_submissions(request, user_id):
|
||||
extra={
|
||||
"admin_user": request.user.username,
|
||||
"target_user_id": user_id,
|
||||
"error": str(e),
|
||||
"detail": str(e),
|
||||
"action": "user_deletion_error",
|
||||
},
|
||||
exc_info=True
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
return Response(
|
||||
{
|
||||
"success": False,
|
||||
"error": f"Error deleting user: {str(e)}",
|
||||
"detail": f"Error deleting user: {str(e)}",
|
||||
"error_code": "DELETION_ERROR",
|
||||
"help_text": "Please try again or contact system administrator if the problem persists.",
|
||||
},
|
||||
@@ -259,8 +254,7 @@ def delete_user_preserve_submissions(request, user_id):
|
||||
},
|
||||
},
|
||||
"example": {
|
||||
"success": True,
|
||||
"message": "Avatar saved successfully",
|
||||
"detail": "Avatar saved successfully",
|
||||
"avatar_url": "https://imagedelivery.net/account-hash/image-id/avatar",
|
||||
"avatar_variants": {
|
||||
"thumbnail": "https://imagedelivery.net/account-hash/image-id/thumbnail",
|
||||
@@ -285,7 +279,7 @@ def save_avatar_image(request):
|
||||
|
||||
if not cloudflare_image_id:
|
||||
return Response(
|
||||
{"success": False, "error": "cloudflare_image_id is required"},
|
||||
{"detail": "cloudflare_image_id is required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
@@ -299,26 +293,25 @@ def save_avatar_image(request):
|
||||
|
||||
if not image_data:
|
||||
return Response(
|
||||
{"success": False, "error": "Image not found in Cloudflare"},
|
||||
{"detail": "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)
|
||||
cloudflare_image = CloudflareImage.objects.get(cloudflare_id=cloudflare_image_id)
|
||||
|
||||
# Update existing record with latest data from Cloudflare
|
||||
cloudflare_image.status = 'uploaded'
|
||||
cloudflare_image.status = "uploaded"
|
||||
cloudflare_image.uploaded_at = timezone.now()
|
||||
cloudflare_image.metadata = image_data.get('meta', {})
|
||||
cloudflare_image.metadata = image_data.get("meta", {})
|
||||
# Extract variants from nested result structure
|
||||
cloudflare_image.variants = image_data.get('result', {}).get('variants', [])
|
||||
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.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:
|
||||
@@ -326,25 +319,23 @@ def save_avatar_image(request):
|
||||
cloudflare_image = CloudflareImage.objects.create(
|
||||
cloudflare_id=cloudflare_image_id,
|
||||
user=user,
|
||||
status='uploaded',
|
||||
upload_url='', # Not needed for uploaded images
|
||||
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', {}),
|
||||
metadata=image_data.get("meta", {}),
|
||||
# Extract variants from nested result structure
|
||||
variants=image_data.get('result', {}).get('variants', []),
|
||||
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', ''),
|
||||
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)
|
||||
logger.error(f"Error fetching image from Cloudflare API: {str(api_error)}", exc_info=True)
|
||||
return Response(
|
||||
{"success": False,
|
||||
"error": f"Failed to fetch image from Cloudflare: {str(api_error)}"},
|
||||
{"detail": f"Failed to fetch image from Cloudflare: {str(api_error)}"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
@@ -391,8 +382,7 @@ def save_avatar_image(request):
|
||||
|
||||
return Response(
|
||||
{
|
||||
"success": True,
|
||||
"message": "Avatar saved successfully",
|
||||
"detail": "Avatar saved successfully",
|
||||
"avatar_url": avatar_url,
|
||||
"avatar_variants": avatar_variants,
|
||||
},
|
||||
@@ -402,7 +392,7 @@ def save_avatar_image(request):
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving avatar image: {str(e)}", exc_info=True)
|
||||
return Response(
|
||||
{"success": False, "error": f"Failed to save avatar: {str(e)}"},
|
||||
{"detail": f"Failed to save avatar: {str(e)}"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
@@ -420,8 +410,7 @@ def save_avatar_image(request):
|
||||
"avatar_url": {"type": "string"},
|
||||
},
|
||||
"example": {
|
||||
"success": True,
|
||||
"message": "Avatar deleted successfully",
|
||||
"detail": "Avatar deleted successfully",
|
||||
"avatar_url": "https://ui-avatars.com/api/?name=J&size=200&background=random&color=fff&bold=true",
|
||||
},
|
||||
},
|
||||
@@ -447,6 +436,7 @@ def delete_avatar(request):
|
||||
# Delete from Cloudflare first, then from database
|
||||
try:
|
||||
from django_cloudflareimages_toolkit.services import CloudflareImagesService
|
||||
|
||||
service = CloudflareImagesService()
|
||||
service.delete_image(avatar_to_delete)
|
||||
logger.info(f"Successfully deleted avatar from Cloudflare: {avatar_to_delete.cloudflare_id}")
|
||||
@@ -461,8 +451,7 @@ def delete_avatar(request):
|
||||
|
||||
return Response(
|
||||
{
|
||||
"success": True,
|
||||
"message": "Avatar deleted successfully",
|
||||
"detail": "Avatar deleted successfully",
|
||||
"avatar_url": avatar_url,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
@@ -471,8 +460,7 @@ def delete_avatar(request):
|
||||
except UserProfile.DoesNotExist:
|
||||
return Response(
|
||||
{
|
||||
"success": True,
|
||||
"message": "No avatar to delete",
|
||||
"detail": "No avatar to delete",
|
||||
"avatar_url": f"https://ui-avatars.com/api/?name={user.username[0].upper()}&size=200&background=random&color=fff&bold=true",
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
@@ -480,7 +468,7 @@ def delete_avatar(request):
|
||||
|
||||
except Exception as e:
|
||||
return Response(
|
||||
{"success": False, "error": f"Failed to delete avatar: {str(e)}"},
|
||||
{"detail": f"Failed to delete avatar: {str(e)}"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
@@ -506,7 +494,7 @@ def request_account_deletion(request):
|
||||
can_delete, reason = UserDeletionService.can_delete_user(user)
|
||||
if not can_delete:
|
||||
return Response(
|
||||
{"success": False, "error": reason},
|
||||
{"detail": reason},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
@@ -515,8 +503,7 @@ def request_account_deletion(request):
|
||||
|
||||
return Response(
|
||||
{
|
||||
"success": True,
|
||||
"message": "Verification code sent to your email",
|
||||
"detail": "Verification code sent to your email",
|
||||
"expires_at": deletion_request.expires_at,
|
||||
"email": user.email,
|
||||
},
|
||||
@@ -534,7 +521,7 @@ def request_account_deletion(request):
|
||||
"user_role": request.user.role,
|
||||
"rejection_reason": str(e),
|
||||
"action": "self_deletion_rejected",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
# Determine error code based on reason
|
||||
@@ -549,8 +536,7 @@ def request_account_deletion(request):
|
||||
|
||||
return Response(
|
||||
{
|
||||
"success": False,
|
||||
"error": error_message,
|
||||
"detail": error_message,
|
||||
"error_code": error_code,
|
||||
"user_info": {
|
||||
"username": request.user.username,
|
||||
@@ -570,16 +556,15 @@ def request_account_deletion(request):
|
||||
extra={
|
||||
"user": request.user.username,
|
||||
"user_id": request.user.user_id,
|
||||
"error": str(e),
|
||||
"detail": str(e),
|
||||
"action": "self_deletion_error",
|
||||
},
|
||||
exc_info=True
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
return Response(
|
||||
{
|
||||
"success": False,
|
||||
"error": f"Error creating deletion request: {str(e)}",
|
||||
"detail": f"Error creating deletion request: {str(e)}",
|
||||
"error_code": "DELETION_REQUEST_ERROR",
|
||||
"help_text": "Please try again or contact support if the problem persists.",
|
||||
},
|
||||
@@ -611,8 +596,7 @@ def request_account_deletion(request):
|
||||
200: {
|
||||
"description": "Account successfully deleted",
|
||||
"example": {
|
||||
"success": True,
|
||||
"message": "Account successfully deleted with submissions preserved",
|
||||
"detail": "Account successfully deleted with submissions preserved",
|
||||
"deleted_user": {
|
||||
"username": "john_doe",
|
||||
"user_id": "1234",
|
||||
@@ -637,7 +621,7 @@ def request_account_deletion(request):
|
||||
},
|
||||
400: {
|
||||
"description": "Invalid or expired verification code",
|
||||
"example": {"success": False, "error": "Verification code has expired"},
|
||||
"example": {"detail": "Verification code has expired"},
|
||||
},
|
||||
},
|
||||
tags=["Self-Service Account Management"],
|
||||
@@ -663,7 +647,7 @@ def verify_account_deletion(request):
|
||||
|
||||
if not verification_code:
|
||||
return Response(
|
||||
{"success": False, "error": "Verification code is required"},
|
||||
{"detail": "Verification code is required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
@@ -672,20 +656,17 @@ def verify_account_deletion(request):
|
||||
|
||||
return Response(
|
||||
{
|
||||
"success": True,
|
||||
"message": "Account successfully deleted with submissions preserved",
|
||||
"detail": "Account successfully deleted with submissions preserved",
|
||||
**result,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
except ValueError as e:
|
||||
return Response(
|
||||
{"success": False, "error": str(e)}, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
except Exception as e:
|
||||
return Response(
|
||||
{"success": False, "error": f"Error verifying deletion: {str(e)}"},
|
||||
{"detail": f"Error verifying deletion: {str(e)}"},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
@@ -701,14 +682,13 @@ def verify_account_deletion(request):
|
||||
200: {
|
||||
"description": "Deletion request cancelled or no request found",
|
||||
"example": {
|
||||
"success": True,
|
||||
"message": "Deletion request cancelled",
|
||||
"detail": "Deletion request cancelled",
|
||||
"had_pending_request": True,
|
||||
},
|
||||
},
|
||||
401: {
|
||||
"description": "Authentication required",
|
||||
"example": {"success": False, "error": "Authentication required"},
|
||||
"example": {"detail": "Authentication required"},
|
||||
},
|
||||
},
|
||||
tags=["Self-Service Account Management"],
|
||||
@@ -732,12 +712,7 @@ def cancel_account_deletion(request):
|
||||
|
||||
return Response(
|
||||
{
|
||||
"success": True,
|
||||
"message": (
|
||||
"Deletion request cancelled"
|
||||
if had_request
|
||||
else "No pending deletion request found"
|
||||
),
|
||||
"detail": ("Deletion request cancelled" if had_request else "No pending deletion request found"),
|
||||
"had_pending_request": had_request,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
@@ -745,7 +720,7 @@ def cancel_account_deletion(request):
|
||||
|
||||
except Exception as e:
|
||||
return Response(
|
||||
{"success": False, "error": f"Error cancelling deletion request: {str(e)}"},
|
||||
{"detail": f"Error cancelling deletion request: {str(e)}"},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
@@ -753,10 +728,7 @@ def cancel_account_deletion(request):
|
||||
@extend_schema(
|
||||
operation_id="check_user_deletion_eligibility",
|
||||
summary="Check if user can be deleted",
|
||||
description=(
|
||||
"Check if a user can be safely deleted and get a preview of "
|
||||
"what submissions would be preserved."
|
||||
),
|
||||
description=("Check if a user can be safely deleted and get a preview of " "what submissions would be preserved."),
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name="user_id",
|
||||
@@ -792,11 +764,11 @@ def cancel_account_deletion(request):
|
||||
},
|
||||
404: {
|
||||
"description": "User not found",
|
||||
"example": {"success": False, "error": "User not found"},
|
||||
"example": {"detail": "User not found"},
|
||||
},
|
||||
403: {
|
||||
"description": "Permission denied - admin access required",
|
||||
"example": {"success": False, "error": "Admin access required"},
|
||||
"example": {"detail": "Admin access required"},
|
||||
},
|
||||
},
|
||||
tags=["User Management"],
|
||||
@@ -821,27 +793,13 @@ def check_user_deletion_eligibility(request, user_id):
|
||||
|
||||
# Count submissions
|
||||
submission_counts = {
|
||||
"park_reviews": getattr(
|
||||
user, "park_reviews", user.__class__.objects.none()
|
||||
).count(),
|
||||
"ride_reviews": getattr(
|
||||
user, "ride_reviews", user.__class__.objects.none()
|
||||
).count(),
|
||||
"uploaded_park_photos": getattr(
|
||||
user, "uploaded_park_photos", user.__class__.objects.none()
|
||||
).count(),
|
||||
"uploaded_ride_photos": getattr(
|
||||
user, "uploaded_ride_photos", user.__class__.objects.none()
|
||||
).count(),
|
||||
"top_lists": getattr(
|
||||
user, "user_lists", user.__class__.objects.none()
|
||||
).count(),
|
||||
"edit_submissions": getattr(
|
||||
user, "edit_submissions", user.__class__.objects.none()
|
||||
).count(),
|
||||
"photo_submissions": getattr(
|
||||
user, "photo_submissions", user.__class__.objects.none()
|
||||
).count(),
|
||||
"park_reviews": getattr(user, "park_reviews", user.__class__.objects.none()).count(),
|
||||
"ride_reviews": getattr(user, "ride_reviews", user.__class__.objects.none()).count(),
|
||||
"uploaded_park_photos": getattr(user, "uploaded_park_photos", user.__class__.objects.none()).count(),
|
||||
"uploaded_ride_photos": getattr(user, "uploaded_ride_photos", user.__class__.objects.none()).count(),
|
||||
"top_lists": getattr(user, "user_lists", user.__class__.objects.none()).count(),
|
||||
"edit_submissions": getattr(user, "edit_submissions", user.__class__.objects.none()).count(),
|
||||
"photo_submissions": getattr(user, "photo_submissions", user.__class__.objects.none()).count(),
|
||||
}
|
||||
|
||||
total_submissions = sum(submission_counts.values())
|
||||
@@ -865,7 +823,7 @@ def check_user_deletion_eligibility(request, user_id):
|
||||
|
||||
except Exception as e:
|
||||
return Response(
|
||||
{"success": False, "error": f"Error checking user: {str(e)}"},
|
||||
{"detail": f"Error checking user: {str(e)}"},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
@@ -912,9 +870,7 @@ def get_user_profile(request):
|
||||
@permission_classes([IsAuthenticated])
|
||||
def update_user_account(request):
|
||||
"""Update basic account information."""
|
||||
serializer = AccountUpdateSerializer(
|
||||
request.user, data=request.data, partial=True, context={"request": request}
|
||||
)
|
||||
serializer = AccountUpdateSerializer(request.user, data=request.data, partial=True, context={"request": request})
|
||||
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
@@ -944,9 +900,7 @@ def update_user_profile(request):
|
||||
"""Update user profile information."""
|
||||
profile, created = UserProfile.objects.get_or_create(user=request.user)
|
||||
|
||||
serializer = ProfileUpdateSerializer(
|
||||
profile, data=request.data, partial=True, context={"request": request}
|
||||
)
|
||||
serializer = ProfileUpdateSerializer(profile, data=request.data, partial=True, context={"request": request})
|
||||
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
@@ -1046,9 +1000,7 @@ def update_user_preferences(request):
|
||||
@permission_classes([IsAuthenticated])
|
||||
def update_theme_preference(request):
|
||||
"""Update theme preference."""
|
||||
serializer = ThemePreferenceSerializer(
|
||||
request.user, data=request.data, partial=True
|
||||
)
|
||||
serializer = ThemePreferenceSerializer(request.user, data=request.data, partial=True)
|
||||
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
@@ -1395,14 +1347,9 @@ def update_top_list(request, list_id):
|
||||
try:
|
||||
top_list = UserList.objects.get(id=list_id, user=request.user)
|
||||
except UserList.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Top list not found"},
|
||||
status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
return Response({"detail": "Top list not found"}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
serializer = UserListSerializer(
|
||||
top_list, data=request.data, partial=True, context={"request": request}
|
||||
)
|
||||
serializer = UserListSerializer(top_list, data=request.data, partial=True, context={"request": request})
|
||||
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
@@ -1430,10 +1377,7 @@ def delete_top_list(request, list_id):
|
||||
top_list.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
except UserList.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Top list not found"},
|
||||
status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
return Response({"detail": "Top list not found"}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
|
||||
# === NOTIFICATION ENDPOINTS ===
|
||||
@@ -1453,9 +1397,9 @@ def delete_top_list(request, list_id):
|
||||
@permission_classes([IsAuthenticated])
|
||||
def get_user_notifications(request):
|
||||
"""Get user notifications."""
|
||||
notifications = UserNotification.objects.filter(
|
||||
user=request.user
|
||||
).order_by("-created_at")[:50] # Limit to 50 most recent
|
||||
notifications = UserNotification.objects.filter(user=request.user).order_by("-created_at")[
|
||||
:50
|
||||
] # Limit to 50 most recent
|
||||
|
||||
serializer = UserNotificationSerializer(notifications, many=True)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
@@ -1483,19 +1427,16 @@ def mark_notifications_read(request):
|
||||
mark_all = serializer.validated_data.get("mark_all", False)
|
||||
|
||||
if mark_all:
|
||||
UserNotification.objects.filter(
|
||||
user=request.user, is_read=False
|
||||
).update(is_read=True, read_at=timezone.now())
|
||||
UserNotification.objects.filter(user=request.user, is_read=False).update(
|
||||
is_read=True, read_at=timezone.now()
|
||||
)
|
||||
count = UserNotification.objects.filter(user=request.user).count()
|
||||
else:
|
||||
count = UserNotification.objects.filter(
|
||||
id__in=notification_ids, user=request.user, is_read=False
|
||||
).update(is_read=True, read_at=timezone.now())
|
||||
count = UserNotification.objects.filter(id__in=notification_ids, user=request.user, is_read=False).update(
|
||||
is_read=True, read_at=timezone.now()
|
||||
)
|
||||
|
||||
return Response(
|
||||
{"message": f"Marked {count} notifications as read"},
|
||||
status=status.HTTP_200_OK
|
||||
)
|
||||
return Response({"detail": f"Marked {count} notifications as read"}, status=status.HTTP_200_OK)
|
||||
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@@ -1544,9 +1485,7 @@ def update_notification_preferences(request):
|
||||
except NotificationPreference.DoesNotExist:
|
||||
preferences = NotificationPreference.objects.create(user=request.user)
|
||||
|
||||
serializer = NotificationPreferenceSerializer(
|
||||
preferences, data=request.data, partial=True
|
||||
)
|
||||
serializer = NotificationPreferenceSerializer(preferences, data=request.data, partial=True)
|
||||
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
@@ -1578,10 +1517,7 @@ def upload_avatar(request):
|
||||
if serializer.is_valid():
|
||||
# Handle avatar upload logic here
|
||||
# This would typically involve saving the file and updating the user profile
|
||||
return Response(
|
||||
{"message": "Avatar uploaded successfully"},
|
||||
status=status.HTTP_200_OK
|
||||
)
|
||||
return Response({"detail": "Avatar uploaded successfully"}, status=status.HTTP_200_OK)
|
||||
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@@ -1596,8 +1532,8 @@ def upload_avatar(request):
|
||||
"example": {
|
||||
"account": {"username": "user", "email": "user@example.com"},
|
||||
"profile": {"display_name": "User"},
|
||||
"content": {"park_reviews": [], "lists": []}
|
||||
}
|
||||
"content": {"park_reviews": [], "lists": []},
|
||||
},
|
||||
},
|
||||
401: {"description": "Authentication required"},
|
||||
},
|
||||
@@ -1612,10 +1548,7 @@ def export_user_data(request):
|
||||
return Response(export_data, status=status.HTTP_200_OK)
|
||||
except Exception as e:
|
||||
logger.error(f"Error exporting data for user {request.user.id}: {e}", exc_info=True)
|
||||
return Response(
|
||||
{"error": "Failed to generate data export"},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
return Response({"detail": "Failed to generate data export"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
|
||||
@extend_schema(
|
||||
@@ -1690,20 +1623,25 @@ def get_login_history(request):
|
||||
# Serialize
|
||||
results = []
|
||||
for entry in entries:
|
||||
results.append({
|
||||
"id": entry.id,
|
||||
"ip_address": entry.ip_address,
|
||||
"user_agent": entry.user_agent[:100] if entry.user_agent else None, # Truncate long user agents
|
||||
"login_method": entry.login_method,
|
||||
"login_method_display": dict(LoginHistory._meta.get_field('login_method').choices).get(entry.login_method, entry.login_method),
|
||||
"login_timestamp": entry.login_timestamp.isoformat(),
|
||||
"country": entry.country,
|
||||
"city": entry.city,
|
||||
"success": entry.success,
|
||||
})
|
||||
|
||||
return Response({
|
||||
"results": results,
|
||||
"count": len(results),
|
||||
})
|
||||
results.append(
|
||||
{
|
||||
"id": entry.id,
|
||||
"ip_address": entry.ip_address,
|
||||
"user_agent": entry.user_agent[:100] if entry.user_agent else None, # Truncate long user agents
|
||||
"login_method": entry.login_method,
|
||||
"login_method_display": dict(LoginHistory._meta.get_field("login_method").choices).get(
|
||||
entry.login_method, entry.login_method
|
||||
),
|
||||
"login_timestamp": entry.login_timestamp.isoformat(),
|
||||
"country": entry.country,
|
||||
"city": entry.city,
|
||||
"success": entry.success,
|
||||
}
|
||||
)
|
||||
|
||||
return Response(
|
||||
{
|
||||
"results": results,
|
||||
"count": len(results),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -15,22 +15,23 @@ class RideCreditViewSet(viewsets.ModelViewSet):
|
||||
ViewSet for managing Ride Credits.
|
||||
Allows users to track rides they have ridden.
|
||||
"""
|
||||
|
||||
serializer_class = RideCreditSerializer
|
||||
permission_classes = [permissions.IsAuthenticatedOrReadOnly]
|
||||
filter_backends = [DjangoFilterBackend, filters.OrderingFilter]
|
||||
filterset_fields = ['user__username', 'ride__park__slug', 'ride__manufacturer__slug']
|
||||
ordering_fields = ['first_ridden_at', 'last_ridden_at', 'created_at', 'count', 'rating', 'display_order']
|
||||
ordering = ['display_order', '-last_ridden_at']
|
||||
filterset_fields = ["user__username", "ride__park__slug", "ride__manufacturer__slug"]
|
||||
ordering_fields = ["first_ridden_at", "last_ridden_at", "created_at", "count", "rating", "display_order"]
|
||||
ordering = ["display_order", "-last_ridden_at"]
|
||||
|
||||
def get_queryset(self):
|
||||
"""
|
||||
Return ride credits.
|
||||
Optionally filter by user via query param ?user=username
|
||||
"""
|
||||
queryset = RideCredit.objects.all().select_related('ride', 'ride__park', 'user')
|
||||
queryset = RideCredit.objects.all().select_related("ride", "ride__park", "user")
|
||||
|
||||
# Filter by user if provided
|
||||
username = self.request.query_params.get('user')
|
||||
username = self.request.query_params.get("user")
|
||||
if username:
|
||||
queryset = queryset.filter(user__username=username)
|
||||
|
||||
@@ -40,64 +41,49 @@ class RideCreditViewSet(viewsets.ModelViewSet):
|
||||
"""Associate the current user with the ride credit."""
|
||||
serializer.save(user=self.request.user)
|
||||
|
||||
@action(detail=False, methods=['post'], permission_classes=[permissions.IsAuthenticated])
|
||||
@action(detail=False, methods=["post"], permission_classes=[permissions.IsAuthenticated])
|
||||
@extend_schema(
|
||||
summary="Reorder ride credits",
|
||||
description="Bulk update the display order of ride credits. Send a list of {id, order} objects.",
|
||||
request={
|
||||
'application/json': {
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'order': {
|
||||
'type': 'array',
|
||||
'items': {
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'id': {'type': 'integer'},
|
||||
'order': {'type': 'integer'}
|
||||
},
|
||||
'required': ['id', 'order']
|
||||
}
|
||||
"application/json": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"order": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {"id": {"type": "integer"}, "order": {"type": "integer"}},
|
||||
"required": ["id", "order"],
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
def reorder(self, request):
|
||||
"""
|
||||
Bulk update display_order for multiple credits.
|
||||
Expects: {"order": [{"id": 1, "order": 0}, {"id": 2, "order": 1}, ...]}
|
||||
"""
|
||||
order_data = request.data.get('order', [])
|
||||
order_data = request.data.get("order", [])
|
||||
|
||||
if not order_data:
|
||||
return Response(
|
||||
{'error': 'No order data provided'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
return Response({"detail": "No order data provided"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Validate that all credits belong to the current user
|
||||
credit_ids = [item['id'] for item in order_data]
|
||||
user_credits = RideCredit.objects.filter(
|
||||
id__in=credit_ids,
|
||||
user=request.user
|
||||
).values_list('id', flat=True)
|
||||
credit_ids = [item["id"] for item in order_data]
|
||||
user_credits = RideCredit.objects.filter(id__in=credit_ids, user=request.user).values_list("id", flat=True)
|
||||
|
||||
if set(credit_ids) != set(user_credits):
|
||||
return Response(
|
||||
{'error': 'You can only reorder your own credits'},
|
||||
status=status.HTTP_403_FORBIDDEN
|
||||
)
|
||||
return Response({"detail": "You can only reorder your own credits"}, status=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
# Bulk update in a transaction
|
||||
with transaction.atomic():
|
||||
for item in order_data:
|
||||
RideCredit.objects.filter(
|
||||
id=item['id'],
|
||||
user=request.user
|
||||
).update(display_order=item['order'])
|
||||
RideCredit.objects.filter(id=item["id"], user=request.user).update(display_order=item["order"])
|
||||
|
||||
return Response({'status': 'reordered', 'count': len(order_data)})
|
||||
return Response({"status": "reordered", "count": len(order_data)})
|
||||
|
||||
@extend_schema(
|
||||
summary="List ride credits",
|
||||
@@ -109,8 +95,7 @@ class RideCreditViewSet(viewsets.ModelViewSet):
|
||||
type=OpenApiTypes.STR,
|
||||
description="Filter by username",
|
||||
),
|
||||
]
|
||||
],
|
||||
)
|
||||
def list(self, request, *args, **kwargs):
|
||||
return super().list(request, *args, **kwargs)
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ Magic Link (Login by Code) API views.
|
||||
Provides API endpoints for passwordless login via email code.
|
||||
Uses django-allauth's built-in login-by-code functionality.
|
||||
"""
|
||||
|
||||
from django.conf import settings
|
||||
from drf_spectacular.utils import OpenApiExample, extend_schema
|
||||
from rest_framework import status
|
||||
@@ -15,6 +16,7 @@ try:
|
||||
from allauth.account.internal.flows.login_by_code import perform_login_by_code, request_login_code
|
||||
from allauth.account.models import EmailAddress
|
||||
from allauth.account.utils import user_email # noqa: F401 - imported to verify availability
|
||||
|
||||
HAS_LOGIN_BY_CODE = True
|
||||
except ImportError:
|
||||
HAS_LOGIN_BY_CODE = False
|
||||
@@ -24,27 +26,19 @@ except ImportError:
|
||||
summary="Request magic link login code",
|
||||
description="Send a one-time login code to the user's email address.",
|
||||
request={
|
||||
'application/json': {
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'email': {'type': 'string', 'format': 'email'}
|
||||
},
|
||||
'required': ['email']
|
||||
"application/json": {
|
||||
"type": "object",
|
||||
"properties": {"email": {"type": "string", "format": "email"}},
|
||||
"required": ["email"],
|
||||
}
|
||||
},
|
||||
responses={
|
||||
200: {'description': 'Login code sent successfully'},
|
||||
400: {'description': 'Invalid email or feature disabled'},
|
||||
200: {"description": "Login code sent successfully"},
|
||||
400: {"description": "Invalid email or feature disabled"},
|
||||
},
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
'Request login code',
|
||||
value={'email': 'user@example.com'},
|
||||
request_only=True
|
||||
)
|
||||
]
|
||||
examples=[OpenApiExample("Request login code", value={"email": "user@example.com"}, request_only=True)],
|
||||
)
|
||||
@api_view(['POST'])
|
||||
@api_view(["POST"])
|
||||
@permission_classes([AllowAny])
|
||||
def request_magic_link(request):
|
||||
"""
|
||||
@@ -55,25 +49,18 @@ def request_magic_link(request):
|
||||
2. If the email exists, a code is sent
|
||||
3. User enters the code to complete login
|
||||
"""
|
||||
if not getattr(settings, 'ACCOUNT_LOGIN_BY_CODE_ENABLED', False):
|
||||
return Response(
|
||||
{'error': 'Magic link login is not enabled'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
if not getattr(settings, "ACCOUNT_LOGIN_BY_CODE_ENABLED", False):
|
||||
return Response({"detail": "Magic link login is not enabled"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
if not HAS_LOGIN_BY_CODE:
|
||||
return Response(
|
||||
{'error': 'Login by code is not available in this version of allauth'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
{"detail": "Login by code is not available in this version of allauth"}, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
email = request.data.get('email', '').lower().strip()
|
||||
email = request.data.get("email", "").lower().strip()
|
||||
|
||||
if not email:
|
||||
return Response(
|
||||
{'error': 'Email is required'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
return Response({"detail": "Email is required"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Check if email exists (don't reveal if it doesn't for security)
|
||||
try:
|
||||
@@ -83,40 +70,39 @@ def request_magic_link(request):
|
||||
# Request the login code
|
||||
request_login_code(request._request, user)
|
||||
|
||||
return Response({
|
||||
'success': True,
|
||||
'message': 'If an account exists with this email, a login code has been sent.',
|
||||
'timeout': getattr(settings, 'ACCOUNT_LOGIN_BY_CODE_TIMEOUT', 300)
|
||||
})
|
||||
return Response(
|
||||
{
|
||||
"detail": "If an account exists with this email, a login code has been sent.",
|
||||
"timeout": getattr(settings, "ACCOUNT_LOGIN_BY_CODE_TIMEOUT", 300),
|
||||
}
|
||||
)
|
||||
|
||||
except EmailAddress.DoesNotExist:
|
||||
# Don't reveal that the email doesn't exist
|
||||
return Response({
|
||||
'success': True,
|
||||
'message': 'If an account exists with this email, a login code has been sent.',
|
||||
'timeout': getattr(settings, 'ACCOUNT_LOGIN_BY_CODE_TIMEOUT', 300)
|
||||
})
|
||||
return Response(
|
||||
{
|
||||
"detail": "If an account exists with this email, a login code has been sent.",
|
||||
"timeout": getattr(settings, "ACCOUNT_LOGIN_BY_CODE_TIMEOUT", 300),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@extend_schema(
|
||||
summary="Verify magic link code",
|
||||
description="Verify the login code and complete the login process.",
|
||||
request={
|
||||
'application/json': {
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'email': {'type': 'string', 'format': 'email'},
|
||||
'code': {'type': 'string'}
|
||||
},
|
||||
'required': ['email', 'code']
|
||||
"application/json": {
|
||||
"type": "object",
|
||||
"properties": {"email": {"type": "string", "format": "email"}, "code": {"type": "string"}},
|
||||
"required": ["email", "code"],
|
||||
}
|
||||
},
|
||||
responses={
|
||||
200: {'description': 'Login successful'},
|
||||
400: {'description': 'Invalid or expired code'},
|
||||
}
|
||||
200: {"description": "Login successful"},
|
||||
400: {"description": "Invalid or expired code"},
|
||||
},
|
||||
)
|
||||
@api_view(['POST'])
|
||||
@api_view(["POST"])
|
||||
@permission_classes([AllowAny])
|
||||
def verify_magic_link(request):
|
||||
"""
|
||||
@@ -124,26 +110,17 @@ def verify_magic_link(request):
|
||||
|
||||
This is the second step of the magic link flow.
|
||||
"""
|
||||
if not getattr(settings, 'ACCOUNT_LOGIN_BY_CODE_ENABLED', False):
|
||||
return Response(
|
||||
{'error': 'Magic link login is not enabled'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
if not getattr(settings, "ACCOUNT_LOGIN_BY_CODE_ENABLED", False):
|
||||
return Response({"detail": "Magic link login is not enabled"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
if not HAS_LOGIN_BY_CODE:
|
||||
return Response(
|
||||
{'error': 'Login by code is not available'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
return Response({"detail": "Login by code is not available"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
email = request.data.get('email', '').lower().strip()
|
||||
code = request.data.get('code', '').strip()
|
||||
email = request.data.get("email", "").lower().strip()
|
||||
code = request.data.get("code", "").strip()
|
||||
|
||||
if not email or not code:
|
||||
return Response(
|
||||
{'error': 'Email and code are required'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
return Response({"detail": "Email and code are required"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
try:
|
||||
email_address = EmailAddress.objects.get(email__iexact=email, verified=True)
|
||||
@@ -153,28 +130,20 @@ def verify_magic_link(request):
|
||||
success = perform_login_by_code(request._request, user, code)
|
||||
|
||||
if success:
|
||||
return Response({
|
||||
'success': True,
|
||||
'message': 'Login successful',
|
||||
'user': {
|
||||
'id': user.id,
|
||||
'username': user.username,
|
||||
'email': user.email
|
||||
return Response(
|
||||
{
|
||||
"detail": "Login successful",
|
||||
"user": {"id": user.id, "username": user.username, "email": user.email},
|
||||
}
|
||||
})
|
||||
)
|
||||
else:
|
||||
return Response(
|
||||
{'error': 'Invalid or expired code. Please request a new one.'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
{"detail": "Invalid or expired code. Please request a new one."}, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
except EmailAddress.DoesNotExist:
|
||||
return Response(
|
||||
{'error': 'Invalid email or code'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
return Response({"detail": "Invalid email or code"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
except Exception:
|
||||
return Response(
|
||||
{'error': 'Invalid or expired code. Please request a new one.'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
{"detail": "Invalid or expired code. Please request a new one."}, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user