petcare-api / api /views.py
Sameer669
Initial PawCare Django backend with JWT auth, RBAC, audit logging, and HF storage
4f01198
"""
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,
})