Spaces:
Sleeping
Sleeping
| """ | |
| 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()] | |
| 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}) | |
| 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() | |
| 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) | |
| 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, | |
| }) | |