feat: Implement initial schema and add various API, service, and management command enhancements across the application.

This commit is contained in:
pacnpal
2026-01-01 15:13:01 -05:00
parent c95f99ca10
commit b243b17af7
413 changed files with 11164 additions and 17433 deletions

View File

@@ -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

View File

@@ -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)),
]

View File

@@ -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),
}
)

View File

@@ -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)

View File

@@ -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
)