From 1ffbfa56920614ec01095e3e9117968b55435bfc Mon Sep 17 00:00:00 2001 From: saani Date: Mon, 24 Nov 2025 13:29:07 +0000 Subject: [PATCH] feat: add API documentation with drf-spectacular and refactor views - Install and configure drf-spectacular for OpenAPI/Swagger documentation - Add Swagger UI endpoints at /api/schema/ and /api/docs/ - Configure SPECTACULAR_SETTINGS with API metadata - Refactor meetings views from function-based to class-based views (ScheduleAppointmentView, RejectAppointmentView, AvailableDatesView, UserAppointmentsView, AppointmentStatsView, UserAppointmentStatsView) - Update URL patterns to use new class-based views - Simplify ALLOWED_HOSTS configuration to accept all hosts This improves API discoverability through interactive documentation and modernizes the codebase by using class-based views for better code organization and reusability. --- booking_system/settings.py | 12 +- booking_system/urls.py | 5 + meetings/urls.py | 24 ++-- meetings/views.py | 218 +++++++++++++++++++------------------ requirements.txt | Bin 986 -> 1696 bytes users/views.py | 8 +- 6 files changed, 141 insertions(+), 126 deletions(-) diff --git a/booking_system/settings.py b/booking_system/settings.py index ecb05dc..75fd018 100644 --- a/booking_system/settings.py +++ b/booking_system/settings.py @@ -12,7 +12,7 @@ SECRET_KEY = os.getenv('JWT_SECRET', 'django-insecure-fallback-secret-key') DEBUG = os.getenv('DEBUG') -ALLOWED_HOSTS = os.getenv('ALLOWED_HOSTS', '*').split(',') +ALLOWED_HOSTS = ["*"] CORS_ALLOWED_ORIGINS = os.getenv( 'CORS_ALLOWED_ORIGINS', @@ -33,6 +33,7 @@ INSTALLED_APPS = [ 'rest_framework', 'rest_framework_simplejwt', 'corsheaders', + 'drf_spectacular', 'users', 'meetings', @@ -142,6 +143,7 @@ DEFAULT_FROM_EMAIL = os.getenv('SMTP_FROM', 'hello@attunehearttherapy.com') REST_FRAMEWORK = { + 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', 'DEFAULT_AUTHENTICATION_CLASSES': ( 'rest_framework_simplejwt.authentication.JWTAuthentication', ), @@ -153,6 +155,14 @@ REST_FRAMEWORK = { ), } +# Configure Spectacular settings +SPECTACULAR_SETTINGS = { + 'TITLE': 'Blog API', + 'DESCRIPTION': 'API for managing users, meetings, and more.', + 'VERSION': '1.0.0', + 'SERVE_INCLUDE_SCHEMA': False, +} + CORS_ALLOWED_ORIGINS = [ "http://localhost:3000", "http://127.0.0.1:3000", diff --git a/booking_system/urls.py b/booking_system/urls.py index d8df884..fe4bd3d 100644 --- a/booking_system/urls.py +++ b/booking_system/urls.py @@ -1,10 +1,15 @@ from django.urls import path, include from django.contrib import admin from .views import api_root +from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView urlpatterns = [ path('admin/', admin.site.urls), path('api/auth/', include('users.urls')), path('api/meetings/', include('meetings.urls')), path('', api_root, name='api-root'), + + # Swagger UI endpoints + path('api/schema/', SpectacularAPIView.as_view(), name='schema'), + path('api/docs/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'), ] \ No newline at end of file diff --git a/meetings/urls.py b/meetings/urls.py index 3caf0e6..7857b81 100644 --- a/meetings/urls.py +++ b/meetings/urls.py @@ -4,12 +4,12 @@ from .views import ( AppointmentRequestListView, AppointmentRequestCreateView, AppointmentRequestDetailView, - schedule_appointment, - reject_appointment, - available_dates, - user_appointments, - appointment_stats, - user_apointment_stats + ScheduleAppointmentView, + RejectAppointmentView, + AvailableDatesView, + UserAppointmentsView, + AppointmentStatsView, + UserAppointmentStatsView ) urlpatterns = [ @@ -19,12 +19,12 @@ urlpatterns = [ path('appointments/create/', AppointmentRequestCreateView.as_view(), name='appointment-create'), path('appointments//', AppointmentRequestDetailView.as_view(), name='appointment-detail'), - path('appointments//schedule/', schedule_appointment, name='appointment-schedule'), - path('appointments//reject/', reject_appointment, name='appointment-reject'), + path('appointments//schedule/', ScheduleAppointmentView.as_view(), name='appointment-schedule'), + path('appointments//reject/', RejectAppointmentView.as_view(), name='appointment-reject'), - path('appointments/available-dates/', available_dates, name='available-dates'), - path('user/appointments/', user_appointments, name='user-appointments'), + path('appointments/available-dates/', AvailableDatesView.as_view(), name='available-dates'), + path('user/appointments/', UserAppointmentsView.as_view(), name='user-appointments'), - path('appointments/stats/', appointment_stats, name='appointment-stats'), - path('user/appointments/stats/', user_apointment_stats, name='user-appointment-stats'), + path('appointments/stats/', AppointmentStatsView.as_view(), name='appointment-stats'), + path('user/appointments/stats/', UserAppointmentStatsView.as_view(), name='user-appointment-stats'), ] \ No newline at end of file diff --git a/meetings/views.py b/meetings/views.py index 65564e8..b825749 100644 --- a/meetings/views.py +++ b/meetings/views.py @@ -1,7 +1,7 @@ from rest_framework import generics, status from rest_framework.decorators import api_view, permission_classes from rest_framework.response import Response -from rest_framework.permissions import IsAuthenticated, AllowAny +from rest_framework.permissions import IsAuthenticated, AllowAny,IsAdminUser from django.utils import timezone from datetime import datetime, timedelta from .models import AdminWeeklyAvailability, AppointmentRequest @@ -14,9 +14,11 @@ from .serializers import ( ) from .email_service import EmailService from users.models import CustomUser +from django.db.models import Count, Q + class AdminAvailabilityView(generics.RetrieveUpdateAPIView): - permission_classes = [IsAuthenticated] + permission_classes = [IsAuthenticated, IsAdminUser] serializer_class = AdminWeeklyAvailabilitySerializer def get_object(self): @@ -63,22 +65,24 @@ class AppointmentRequestDetailView(generics.RetrieveAPIView): serializer_class = AppointmentRequestSerializer lookup_field = 'pk' -@api_view(['POST']) -@permission_classes([IsAuthenticated]) -def schedule_appointment(request, pk): - try: - appointment = AppointmentRequest.objects.get(pk=pk) - except AppointmentRequest.DoesNotExist: - return Response({'error': 'Appointment not found'}, status=status.HTTP_404_NOT_FOUND) +class ScheduleAppointmentView(generics.GenericAPIView): + permission_classes = [IsAuthenticated, IsAdminUser] + serializer_class = AppointmentScheduleSerializer + queryset = AppointmentRequest.objects.all() + lookup_field = 'pk' - if appointment.status != 'pending_review': - return Response( - {'error': 'Only pending review appointments can be scheduled.'}, - status=status.HTTP_400_BAD_REQUEST - ) - - serializer = AppointmentScheduleSerializer(data=request.data) - if serializer.is_valid(): + def post(self, request, pk): + appointment = self.get_object() + + if appointment.status != 'pending_review': + return Response( + {'error': 'Only pending review appointments can be scheduled.'}, + status=status.HTTP_400_BAD_REQUEST + ) + + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + appointment.schedule_appointment( datetime_obj=serializer.validated_data['scheduled_datetime'], duration=serializer.validated_data['scheduled_duration'] @@ -92,104 +96,106 @@ def schedule_appointment(request, pk): 'message': 'Appointment scheduled successfully. Jitsi meeting created.', 'jitsi_meeting_created': appointment.has_jitsi_meeting }) - - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) -@api_view(['POST']) -@permission_classes([IsAuthenticated]) -def reject_appointment(request, pk): - try: - appointment = AppointmentRequest.objects.get(pk=pk) - except AppointmentRequest.DoesNotExist: - return Response({'error': 'Appointment not found'}, status=status.HTTP_404_NOT_FOUND) +class RejectAppointmentView(generics.GenericAPIView): + permission_classes = [IsAuthenticated] + serializer_class = AppointmentRejectSerializer + queryset = AppointmentRequest.objects.all() + lookup_field = 'pk' - if appointment.status != 'pending_review': - return Response( - {'error': 'Only pending appointments can be rejected'}, - status=status.HTTP_400_BAD_REQUEST + def post(self, request, pk): + appointment = self.get_object() + + if appointment.status != 'pending_review': + return Response( + {'error': 'Only pending appointments can be rejected'}, + status=status.HTTP_400_BAD_REQUEST + ) + + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + + appointment.reject_appointment( + serializer.validated_data.get('rejection_reason', '') ) - - serializer = AppointmentRejectSerializer(data=request.data) - if serializer.is_valid(): - appointment.reject_appointment(serializer.validated_data.get('rejection_reason', '')) EmailService.send_appointment_rejected(appointment) - return Response(AppointmentRequestSerializer(appointment).data) + + response_serializer = AppointmentRequestSerializer(appointment) + return Response(response_serializer.data) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) -@api_view(['GET']) -@permission_classes([AllowAny]) -def available_dates(request): - availability = AdminWeeklyAvailability.objects.first() - if not availability: - return Response([]) +class AvailableDatesView(generics.GenericAPIView): + permission_classes = [AllowAny] - available_days = availability.available_days - today = timezone.now().date() - available_dates = [] - - for i in range(1, 31): - date = today + timedelta(days=i) - if date.weekday() in available_days: - available_dates.append(date.strftime('%Y-%m-%d')) - - return Response(available_dates) + def get(self, request): + availability = AdminWeeklyAvailability.objects.first() + if not availability: + return Response([]) + + available_days = availability.available_days + today = timezone.now().date() + available_dates = [] + + for i in range(1, 31): + date = today + timedelta(days=i) + if date.weekday() in available_days: + available_dates.append(date.strftime('%Y-%m-%d')) + + return Response(available_dates) -@api_view(['GET']) -@permission_classes([IsAuthenticated]) -def user_appointments(request): - appointments = AppointmentRequest.objects.filter( - email=request.user.email - ).order_by('-created_at') +class UserAppointmentsView(generics.ListAPIView): + permission_classes = [IsAuthenticated] + serializer_class = AppointmentRequestSerializer + + def get_queryset(self): + return AppointmentRequest.objects.filter( + email=self.request.user.email + ).order_by('-created_at') - serializer = AppointmentRequestSerializer(appointments, many=True) - return Response(serializer.data) -@api_view(['GET']) -@permission_classes([IsAuthenticated]) -def appointment_stats(request): - if not request.user.is_staff: - return Response( - {'error': 'Unauthorized'}, - status=status.HTTP_403_FORBIDDEN +class AppointmentStatsView(generics.GenericAPIView): + permission_classes = [IsAuthenticated, IsAdminUser] + + def get(self, request): + total = AppointmentRequest.objects.count() + users = CustomUser.objects.filter(is_staff=False).count() + pending = AppointmentRequest.objects.filter(status='pending_review').count() + scheduled = AppointmentRequest.objects.filter(status='scheduled').count() + rejected = AppointmentRequest.objects.filter(status='rejected').count() + + return Response({ + 'total_requests': total, + 'pending_review': pending, + 'scheduled': scheduled, + 'rejected': rejected, + 'users': users, + 'completion_rate': round((scheduled / total * 100), 2) if total > 0 else 0 + }) + +class UserAppointmentStatsView(generics.GenericAPIView): + permission_classes = [IsAuthenticated] + + def get(self, request): + stats = AppointmentRequest.objects.filter( + email=request.user.email + ).aggregate( + total=Count('id'), + pending=Count('id', filter=Q(status='pending_review')), + scheduled=Count('id', filter=Q(status='scheduled')), + rejected=Count('id', filter=Q(status='rejected')), + completed=Count('id', filter=Q(status='completed')) ) - - total = AppointmentRequest.objects.count() - users = CustomUser.objects.filter(is_staff=False).count() - pending = AppointmentRequest.objects.filter(status='pending_review').count() - scheduled = AppointmentRequest.objects.filter(status='scheduled').count() - rejected = AppointmentRequest.objects.filter(status='rejected').count() - - return Response({ - 'total_requests': total, - 'pending_review': pending, - 'scheduled': scheduled, - 'rejected': rejected, - 'users': users, - 'completion_rate': round((scheduled / total * 100), 2) if total > 0 else 0 - }) - -@api_view(['GET']) -@permission_classes([IsAuthenticated]) -def user_apointment_stats(request): - if not request.user.is_staff: - return Response( - {'error': 'Unauthorized'}, - status=status.HTTP_403_FORBIDDEN - ) - - total = AppointmentRequest.objects.filter(email=request.user.email).count() - pending = AppointmentRequest.objects.filter(email=request.user.email, status='pending_review').count() - scheduled = AppointmentRequest.objects.filter(email=request.user.email, status='scheduled').count() - rejected = AppointmentRequest.objects.filter(email=request.user.email, status='rejected').count() - completed = AppointmentRequest.objects.filter(email=request.user.email, status='completed').count() - - return Response({ - 'total_requests': total, - 'pending_review': pending, - 'scheduled': scheduled, - 'rejected': rejected, - 'completed': completed, - 'completion_rate': round((scheduled / total * 100), 2) if total > 0 else 0 - }) + + total = stats['total'] + scheduled = stats['scheduled'] + completion_rate = round((scheduled / total * 100), 2) if total > 0 else 0 + + return Response({ + 'total_requests': total, + 'pending_review': stats['pending'], + 'scheduled': scheduled, + 'rejected': stats['rejected'], + 'completed': stats['completed'], + 'completion_rate': completion_rate + }) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index d056f49a0474905ac182fd7e067ec6d176b87908..7057719202a6195f890968fe10fcfbe8f54b35b6 100644 GIT binary patch literal 1696 zcma)+OK%fF5QO`T#80v1T^tAp4oI9LA|ZiA#45W z?&|8An*Q~365E(Z6J69Xi#NPp#4w5&@ji;@@hm2>Z$yT}Qm3x;bQw z4p~OINGKKYK10|UzlyK)5Pl8LAl9+r*NXp^AU-Di%u9X82RvofRH?pB`N}eGpVh@a zT5vZ^*@4-Ty-z&H@iK83GO^~ojXS#ErgbGNu%nB zP@<2$XIHuNoGxo}t*;d-lY@JI326Lma?~9)LVfB{op_$*Z+X3%zFATtv&=U!DyNy<4^Cr|>e8*}KngZu|Zg delta 33 rcmV++0N($g4cZ5gB#}aTvEpO_lfVKzlOhDblPU!ylfVU>lY|Bw