""" PawCare API Views — all 20+ REST endpoints. """ import logging from django.contrib.auth import get_user_model from django.http import JsonResponse from django.utils import timezone from rest_framework import generics, status, viewsets, filters from rest_framework.decorators import action from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView from rest_framework_simplejwt.tokens import RefreshToken from rest_framework_simplejwt.views import TokenObtainPairView from rest_framework_simplejwt.exceptions import TokenError from django_filters.rest_framework import DjangoFilterBackend from api.models import Caregiver, Pet, Service, Booking, Conversation, Message, BookingStatus from api.permissions import IsAdmin, IsAdminOrReadOnly, IsOwnerOrAdmin, IsParticipantOrAdmin, IsCaregiverOwnerOrAdmin from api.serializers import ( CustomTokenObtainPairSerializer, RegisterSerializer, UserProfileSerializer, CaregiverListSerializer, CaregiverDetailSerializer, CaregiverWriteSerializer, PetSerializer, ServiceSerializer, BookingCreateSerializer, BookingSerializer, BookingStatusUpdateSerializer, BookingReviewSerializer, ConversationSerializer, MessageSerializer, ImageUploadSerializer, ) from storage.hf_storage import hf_storage, HFStorageError logger = logging.getLogger('api') User = get_user_model() # ── Axes lockout handler ─────────────────────────────────────────────────── def lockout(request, credentials, *args, **kwargs): return JsonResponse( {'detail': 'Too many failed login attempts. Account locked for 30 minutes.'}, status=429, ) # ───────────────────────────────────────────────────────────────────────────── # Auth Views # ───────────────────────────────────────────────────────────────────────────── class LoginView(TokenObtainPairView): """POST /api/auth/login/ → access + refresh tokens + user info.""" serializer_class = CustomTokenObtainPairSerializer permission_classes = [AllowAny] class RegisterView(generics.CreateAPIView): """POST /api/auth/register/ → create user account (role=user).""" serializer_class = RegisterSerializer permission_classes = [AllowAny] def create(self, request, *args, **kwargs): serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) user = serializer.save() refresh = RefreshToken.for_user(user) return Response({ 'access': str(refresh.access_token), 'refresh': str(refresh), 'user': { 'id': str(user.id), 'email': user.email, 'full_name': user.full_name, 'role': user.role, }, }, status=status.HTTP_201_CREATED) class LogoutView(APIView): """POST /api/auth/logout/ → blacklist refresh token.""" permission_classes = [IsAuthenticated] def post(self, request): try: token = RefreshToken(request.data.get('refresh')) token.blacklist() return Response({'detail': 'Logged out successfully.'}) except TokenError as exc: return Response({'detail': str(exc)}, status=status.HTTP_400_BAD_REQUEST) class MeView(generics.RetrieveUpdateAPIView): """GET/PATCH /api/auth/me/ → own profile.""" serializer_class = UserProfileSerializer permission_classes = [IsAuthenticated] def get_object(self): return self.request.user class ChangePasswordView(APIView): """POST /api/auth/change-password/""" permission_classes = [IsAuthenticated] def post(self, request): user = request.user old_pw = request.data.get('old_password', '') new_pw = request.data.get('new_password', '') if not user.check_password(old_pw): return Response({'detail': 'Incorrect current password.'}, status=400) if len(new_pw) < 10: return Response({'detail': 'Password must be at least 10 characters.'}, status=400) user.set_password(new_pw) user.save() return Response({'detail': 'Password changed successfully.'}) # ───────────────────────────────────────────────────────────────────────────── # Service Views # ───────────────────────────────────────────────────────────────────────────── class ServiceListView(generics.ListAPIView): """GET /api/services/""" queryset = Service.objects.filter(is_active=True) serializer_class = ServiceSerializer permission_classes = [AllowAny] # ───────────────────────────────────────────────────────────────────────────── # Caregiver Views # ───────────────────────────────────────────────────────────────────────────── class CaregiverViewSet(viewsets.ModelViewSet): """ GET /api/caregivers/ → list (public, filterable) GET /api/caregivers/{id}/ → detail (public) POST /api/caregivers/ → create (admin only) PUT /api/caregivers/{id}/ → update (admin only) PATCH /api/caregivers/{id}/ → partial update (admin or self) DELETE /api/caregivers/{id}/ → delete (admin only) """ queryset = Caregiver.objects.select_related('user').prefetch_related('services') filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter] filterset_fields = ['city', 'country', 'is_available', 'is_verified', 'is_featured'] search_fields = ['user__first_name', 'user__last_name', 'bio', 'city', 'specializations'] ordering_fields = ['rating', 'total_bookings', 'years_of_experience', 'created_at'] ordering = ['-rating'] def get_serializer_class(self): if self.action == 'list': return CaregiverListSerializer if self.action in ('create', 'update', 'partial_update'): return CaregiverWriteSerializer return CaregiverDetailSerializer def get_permissions(self): if self.action in ('list', 'retrieve'): return [AllowAny()] if self.action in ('update', 'partial_update'): return [IsAuthenticated(), IsCaregiverOwnerOrAdmin()] return [IsAuthenticated(), IsAdmin()] @action(detail=True, methods=['post'], permission_classes=[IsAuthenticated, IsAdmin]) def toggle_featured(self, request, pk=None): caregiver = self.get_object() caregiver.is_featured = not caregiver.is_featured caregiver.save(update_fields=['is_featured']) return Response({'is_featured': caregiver.is_featured}) @action(detail=True, methods=['post'], permission_classes=[IsAuthenticated, IsAdmin]) def verify(self, request, pk=None): caregiver = self.get_object() caregiver.is_verified = True caregiver.background_check_passed = True caregiver.save(update_fields=['is_verified', 'background_check_passed']) return Response({'is_verified': True}) # ───────────────────────────────────────────────────────────────────────────── # Pet Views # ───────────────────────────────────────────────────────────────────────────── class PetViewSet(viewsets.ModelViewSet): """ Scoped to the requesting user — users only see their own pets. GET /api/pets/ POST /api/pets/ GET /api/pets/{id}/ PUT /api/pets/{id}/ DELETE /api/pets/{id}/ """ serializer_class = PetSerializer permission_classes = [IsAuthenticated, IsOwnerOrAdmin] def get_queryset(self): if self.request.user.role == 'admin' or self.request.user.is_staff: return Pet.objects.all() return Pet.objects.filter(owner=self.request.user) def perform_create(self, serializer): serializer.save(owner=self.request.user) # ───────────────────────────────────────────────────────────────────────────── # Booking Views # ───────────────────────────────────────────────────────────────────────────── class BookingViewSet(viewsets.ModelViewSet): """ GET /api/bookings/ → own bookings (or all for admin) POST /api/bookings/ → create booking GET /api/bookings/{id}/ → detail PATCH /api/bookings/{id}/status/ → update status POST /api/bookings/{id}/review/ → leave review """ permission_classes = [IsAuthenticated, IsOwnerOrAdmin] filter_backends = [DjangoFilterBackend, filters.OrderingFilter] filterset_fields = ['status', 'caregiver', 'pet', 'service'] ordering_fields = ['scheduled_start', 'created_at'] ordering = ['-created_at'] def get_serializer_class(self): if self.action == 'create': return BookingCreateSerializer return BookingSerializer def get_queryset(self): user = self.request.user if user.role == 'admin' or user.is_staff: return Booking.objects.select_related('user', 'caregiver__user', 'pet', 'service').all() if user.role == 'caregiver': return Booking.objects.filter(caregiver__user=user).select_related() return Booking.objects.filter(user=user).select_related() @action(detail=True, methods=['patch'], url_path='status') def update_status(self, request, pk=None): booking = self.get_object() serializer = BookingStatusUpdateSerializer(data=request.data) serializer.is_valid(raise_exception=True) new_status = serializer.validated_data['status'] reason = serializer.validated_data.get('cancellation_reason', '') # Guard transitions allowed = { BookingStatus.PENDING: [BookingStatus.CONFIRMED, BookingStatus.CANCELLED], BookingStatus.CONFIRMED: [BookingStatus.IN_PROGRESS, BookingStatus.CANCELLED], BookingStatus.IN_PROGRESS: [BookingStatus.COMPLETED], } current = booking.status if new_status not in allowed.get(current, []): return Response( {'detail': f'Cannot transition from {current!r} to {new_status!r}.'}, status=status.HTTP_400_BAD_REQUEST, ) booking.status = new_status if new_status == BookingStatus.CANCELLED: booking.cancellation_reason = reason elif new_status == BookingStatus.IN_PROGRESS: booking.actual_start = timezone.now() elif new_status == BookingStatus.COMPLETED: booking.actual_end = timezone.now() booking.caregiver.total_bookings += 1 booking.caregiver.save(update_fields=['total_bookings']) booking.save() return Response(BookingSerializer(booking).data) @action(detail=True, methods=['post']) def review(self, request, pk=None): booking = self.get_object() if booking.user != request.user: return Response({'detail': 'Only the booking owner can leave a review.'}, status=403) if booking.status != BookingStatus.COMPLETED: return Response({'detail': 'Can only review completed bookings.'}, status=400) if booking.reviewed_at: return Response({'detail': 'This booking has already been reviewed.'}, status=400) serializer = BookingReviewSerializer(data=request.data) serializer.is_valid(raise_exception=True) serializer.update(booking, serializer.validated_data) return Response({'detail': 'Review submitted successfully.'}) # ───────────────────────────────────────────────────────────────────────────── # Messaging Views # ───────────────────────────────────────────────────────────────────────────── class ConversationViewSet(viewsets.ReadOnlyModelViewSet): """GET /api/conversations/ {id}/""" serializer_class = ConversationSerializer permission_classes = [IsAuthenticated] def get_queryset(self): return Conversation.objects.filter( participants=self.request.user ).prefetch_related('participants', 'messages') class MessageViewSet(viewsets.ModelViewSet): """ GET /api/conversations/{conv_id}/messages/ POST /api/conversations/{conv_id}/messages/ """ serializer_class = MessageSerializer permission_classes = [IsAuthenticated, IsParticipantOrAdmin] http_method_names = ['get', 'post', 'head', 'options'] def get_queryset(self): conv_id = self.kwargs.get('conversation_pk') return Message.objects.filter( conversation_id=conv_id, conversation__participants=self.request.user, ).select_related('sender') def perform_create(self, serializer): conv_id = self.kwargs.get('conversation_pk') try: conversation = Conversation.objects.get( id=conv_id, participants=self.request.user ) except Conversation.DoesNotExist: from rest_framework.exceptions import PermissionDenied raise PermissionDenied('You are not part of this conversation.') serializer.save(sender=self.request.user, conversation=conversation) # Mark other messages as read Message.objects.filter( conversation=conversation, is_read=False ).exclude(sender=self.request.user).update( is_read=True, read_at=timezone.now() ) # Bump conversation updated_at conversation.save(update_fields=['updated_at']) # ───────────────────────────────────────────────────────────────────────────── # Image Upload View # ───────────────────────────────────────────────────────────────────────────── class ImageUploadView(APIView): """POST /api/upload-image/ → returns {url: '...'}""" permission_classes = [IsAuthenticated] def post(self, request): serializer = ImageUploadSerializer(data=request.data) serializer.is_valid(raise_exception=True) file_obj = serializer.validated_data['image'] folder = serializer.validated_data.get('folder', 'images') try: url = hf_storage.upload_image(file_obj, folder=folder) return Response({'url': url}, status=status.HTTP_201_CREATED) except HFStorageError as exc: return Response({'detail': str(exc)}, status=status.HTTP_502_BAD_GATEWAY) except ValueError as exc: return Response({'detail': str(exc)}, status=status.HTTP_400_BAD_REQUEST) # ───────────────────────────────────────────────────────────────────────────── # Dashboard Stats (Admin only) # ───────────────────────────────────────────────────────────────────────────── class DashboardStatsView(APIView): """GET /api/admin/stats/""" permission_classes = [IsAuthenticated, IsAdmin] def get(self, request): from django.db.models import Count, Avg, Sum return Response({ 'users': User.objects.filter(role='user').count(), 'caregivers': Caregiver.objects.count(), 'verified_caregivers': Caregiver.objects.filter(is_verified=True).count(), 'pets': Pet.objects.count(), 'bookings': { 'total': Booking.objects.count(), 'pending': Booking.objects.filter(status='pending').count(), 'confirmed': Booking.objects.filter(status='confirmed').count(), 'completed': Booking.objects.filter(status='completed').count(), 'cancelled': Booking.objects.filter(status='cancelled').count(), }, 'revenue': Booking.objects.filter( status='completed' ).aggregate(total=Sum('price_total'))['total'] or 0, 'avg_rating': Caregiver.objects.aggregate( avg=Avg('rating') )['avg'] or 0, })