feat: Implement MFA authentication, add ride statistics model, and update various services, APIs, and tests across the application.

This commit is contained in:
pacnpal
2025-12-28 17:32:53 -05:00
parent aa56c46c27
commit c95f99ca10
452 changed files with 7948 additions and 6073 deletions

View File

@@ -1,5 +1,6 @@
from rest_framework import serializers
from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers
from apps.accounts.models import UserProfile
from apps.accounts.serializers import UserSerializer # existing shared user serializer
@@ -24,7 +25,7 @@ class UserProfileUpdateInputSerializer(serializers.ModelSerializer):
from django_cloudflareimages_toolkit.models import CloudflareImage
image, _ = CloudflareImage.objects.get_or_create(cloudflare_id=cloudflare_id)
instance.avatar = image
return super().update(instance, validated_data)

View File

@@ -2,8 +2,14 @@
URL configuration for user account management API endpoints.
"""
from django.urls import path
from . import views
from django.urls import include, path
from rest_framework.routers import DefaultRouter
from . import views, views_credits, views_magic_link
# Register ViewSets
router = DefaultRouter()
router.register(r"credits", views_credits.RideCreditViewSet, basename="ride-credit")
urlpatterns = [
# Admin endpoints for user management
@@ -108,19 +114,18 @@ 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"),
]
# Register ViewSets
from rest_framework.routers import DefaultRouter
from . import views_credits
from django.urls import include
router = DefaultRouter()
router.register(r"credits", views_credits.RideCreditViewSet, basename="ride-credit")
urlpatterns += [
# ViewSet routes
path("", include(router.urls)),
]

View File

@@ -6,43 +6,44 @@ user deletion while preserving submissions, profile management, settings,
preferences, privacy, notifications, and security.
"""
from apps.api.v1.serializers.accounts import (
CompleteUserSerializer,
PublicUserSerializer,
UserPreferencesSerializer,
NotificationSettingsSerializer,
PrivacySettingsSerializer,
SecuritySettingsSerializer,
UserStatisticsSerializer,
UserListSerializer,
AccountUpdateSerializer,
ProfileUpdateSerializer,
ThemePreferenceSerializer,
UserNotificationSerializer,
NotificationPreferenceSerializer,
MarkNotificationsReadSerializer,
AvatarUploadSerializer,
)
from apps.accounts.services import UserDeletionService
from apps.accounts.export_service import UserExportService
from apps.accounts.models import (
User,
UserProfile,
UserNotification,
NotificationPreference,
)
from apps.lists.models import UserList
import logging
from rest_framework import status
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated, IsAdminUser
from rest_framework.response import Response
from drf_spectacular.utils import extend_schema, OpenApiParameter
from drf_spectacular.types import OpenApiTypes
from django.shortcuts import get_object_or_404
from rest_framework.permissions import AllowAny
from django.utils import timezone
from django_cloudflareimages_toolkit.models import CloudflareImage
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, extend_schema
from rest_framework import status
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import AllowAny, IsAdminUser, IsAuthenticated
from rest_framework.response import Response
from apps.accounts.export_service import UserExportService
from apps.accounts.models import (
NotificationPreference,
User,
UserNotification,
UserProfile,
)
from apps.accounts.services import UserDeletionService
from apps.api.v1.serializers.accounts import (
AccountUpdateSerializer,
AvatarUploadSerializer,
CompleteUserSerializer,
MarkNotificationsReadSerializer,
NotificationPreferenceSerializer,
NotificationSettingsSerializer,
PrivacySettingsSerializer,
ProfileUpdateSerializer,
PublicUserSerializer,
SecuritySettingsSerializer,
ThemePreferenceSerializer,
UserListSerializer,
UserNotificationSerializer,
UserPreferencesSerializer,
UserStatisticsSerializer,
)
from apps.lists.models import UserList
# Set up logging
logger = logging.getLogger(__name__)
@@ -307,7 +308,7 @@ def save_avatar_image(request):
try:
cloudflare_image = CloudflareImage.objects.get(
cloudflare_id=cloudflare_image_id)
# Update existing record with latest data from Cloudflare
cloudflare_image.status = 'uploaded'
cloudflare_image.uploaded_at = timezone.now()
@@ -319,7 +320,7 @@ def save_avatar_image(request):
cloudflare_image.height = image_data.get('height')
cloudflare_image.format = image_data.get('format', '')
cloudflare_image.save()
except CloudflareImage.DoesNotExist:
# Create new CloudflareImage record from API response
cloudflare_image = CloudflareImage.objects.create(
@@ -367,7 +368,7 @@ def save_avatar_image(request):
except Exception as e:
logger.error(f"Failed to delete old avatar from Cloudflare: {str(e)}")
# Continue with database deletion even if Cloudflare deletion fails
old_avatar.delete()
# Debug logging to see what's happening with the CloudflareImage
@@ -442,7 +443,7 @@ def delete_avatar(request):
avatar_to_delete = profile.avatar
profile.avatar = None
profile.save()
# Delete from Cloudflare first, then from database
try:
from django_cloudflareimages_toolkit.services import CloudflareImagesService
@@ -452,7 +453,7 @@ def delete_avatar(request):
except Exception as e:
logger.error(f"Failed to delete avatar from Cloudflare: {str(e)}")
# Continue with database deletion even if Cloudflare deletion fails
avatar_to_delete.delete()
# Get the default avatar URL
@@ -1273,10 +1274,10 @@ def update_security_settings(request):
# Handle security settings updates
if "two_factor_enabled" in request.data:
setattr(user, "two_factor_enabled", request.data["two_factor_enabled"])
user.two_factor_enabled = request.data["two_factor_enabled"]
if "login_notifications" in request.data:
setattr(user, "login_notifications", request.data["login_notifications"])
user.login_notifications = request.data["login_notifications"]
user.save()
@@ -1612,7 +1613,7 @@ def export_user_data(request):
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"},
{"error": "Failed to generate data export"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
@@ -1636,54 +1637,73 @@ def get_public_user_profile(request, username):
return Response(serializer.data, status=status.HTTP_200_OK)
# === MISSING FUNCTION IMPLEMENTATIONS ===
@extend_schema(
operation_id="request_account_deletion",
summary="Request account deletion",
description="Request deletion of the authenticated user's account.",
operation_id="get_login_history",
summary="Get user login history",
description=(
"Returns the authenticated user's recent login history including "
"IP addresses, devices, and timestamps for security auditing."
),
parameters=[
OpenApiParameter(
name="limit",
type=OpenApiTypes.INT,
location=OpenApiParameter.QUERY,
description="Maximum number of entries to return (default: 20, max: 100)",
),
],
responses={
200: {"description": "Deletion request created"},
400: {"description": "Cannot delete account"},
},
tags=["Self-Service Account Management"],
)
@api_view(["POST"])
@permission_classes([IsAuthenticated])
def request_account_deletion(request):
"""Request account deletion."""
try:
user = request.user
# Check if user can be deleted
can_delete, reason = UserDeletionService.can_delete_user(user)
if not can_delete:
return Response(
{"success": False, "error": reason},
status=status.HTTP_400_BAD_REQUEST,
)
# Create deletion request
deletion_request = UserDeletionService.create_deletion_request(user)
return Response(
{
"success": True,
"message": "Verification code sent to your email",
"expires_at": deletion_request.expires_at,
"email": user.email,
200: {
"description": "Login history entries",
"example": {
"results": [
{
"id": 1,
"ip_address": "192.168.1.1",
"user_agent": "Mozilla/5.0...",
"login_method": "PASSWORD",
"login_method_display": "Password",
"login_timestamp": "2024-12-27T10:30:00Z",
"country": "United States",
"city": "New York",
}
],
"count": 1,
},
status=status.HTTP_200_OK,
)
},
401: {"description": "Authentication required"},
},
tags=["User Security"],
)
@api_view(["GET"])
@permission_classes([IsAuthenticated])
def get_login_history(request):
"""Get user login history for security auditing."""
from apps.accounts.login_history import LoginHistory
user = request.user
limit = min(int(request.query_params.get("limit", 20)), 100)
# Get login history for user
entries = LoginHistory.objects.filter(user=user).order_by("-login_timestamp")[:limit]
# 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),
})
except ValueError as e:
return Response(
{"success": False, "error": str(e)},
status=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
return Response(
{"success": False, "error": f"Error creating deletion request: {str(e)}"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)

View File

@@ -1,9 +1,14 @@
from rest_framework import viewsets, permissions, filters
from django.db import transaction
from django_filters.rest_framework import DjangoFilterBackend
from apps.rides.models.credits import RideCredit
from apps.api.v1.serializers.ride_credits import RideCreditSerializer
from drf_spectacular.utils import extend_schema, OpenApiParameter
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, extend_schema
from rest_framework import filters, permissions, status, viewsets
from rest_framework.decorators import action
from rest_framework.response import Response
from apps.api.v1.serializers.ride_credits import RideCreditSerializer
from apps.rides.models.credits import RideCredit
class RideCreditViewSet(viewsets.ModelViewSet):
"""
@@ -14,8 +19,8 @@ class RideCreditViewSet(viewsets.ModelViewSet):
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']
ordering = ['-last_ridden_at']
ordering_fields = ['first_ridden_at', 'last_ridden_at', 'created_at', 'count', 'rating', 'display_order']
ordering = ['display_order', '-last_ridden_at']
def get_queryset(self):
"""
@@ -23,18 +28,77 @@ class RideCreditViewSet(viewsets.ModelViewSet):
Optionally filter by user via query param ?user=username
"""
queryset = RideCredit.objects.all().select_related('ride', 'ride__park', 'user')
# Filter by user if provided
username = self.request.query_params.get('user')
if username:
queryset = queryset.filter(user__username=username)
return queryset
def perform_create(self, serializer):
"""Associate the current user with the ride credit."""
serializer.save(user=self.request.user)
@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']
}
}
}
}
}
)
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', [])
if not order_data:
return Response(
{'error': '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)
if set(credit_ids) != set(user_credits):
return Response(
{'error': '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'])
return Response({'status': 'reordered', 'count': len(order_data)})
@extend_schema(
summary="List ride credits",
description="List ride credits. filter by user username.",
@@ -49,3 +113,4 @@ class RideCreditViewSet(viewsets.ModelViewSet):
)
def list(self, request, *args, **kwargs):
return super().list(request, *args, **kwargs)

View File

@@ -0,0 +1,180 @@
"""
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
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
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
@extend_schema(
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']
}
},
responses={
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
)
]
)
@api_view(['POST'])
@permission_classes([AllowAny])
def request_magic_link(request):
"""
Request a login code to be sent to the user's email.
This is the first step of the magic link flow:
1. User enters their email
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 HAS_LOGIN_BY_CODE:
return Response(
{'error': 'Login by code is not available in this version of allauth'},
status=status.HTTP_400_BAD_REQUEST
)
email = request.data.get('email', '').lower().strip()
if not email:
return Response(
{'error': 'Email is required'},
status=status.HTTP_400_BAD_REQUEST
)
# Check if email exists (don't reveal if it doesn't for security)
try:
email_address = EmailAddress.objects.get(email__iexact=email, verified=True)
user = email_address.user
# 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)
})
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)
})
@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']
}
},
responses={
200: {'description': 'Login successful'},
400: {'description': 'Invalid or expired code'},
}
)
@api_view(['POST'])
@permission_classes([AllowAny])
def verify_magic_link(request):
"""
Verify the login code and complete the login.
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 HAS_LOGIN_BY_CODE:
return Response(
{'error': '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()
if not email or not code:
return Response(
{'error': 'Email and code are required'},
status=status.HTTP_400_BAD_REQUEST
)
try:
email_address = EmailAddress.objects.get(email__iexact=email, verified=True)
user = email_address.user
# Attempt to verify the code and log in
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
}
})
else:
return Response(
{'error': '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
)
except Exception:
return Response(
{'error': 'Invalid or expired code. Please request a new one.'},
status=status.HTTP_400_BAD_REQUEST
)