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

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