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.
This commit is contained in:
saani 2025-11-24 13:29:07 +00:00
parent 9aef796fc7
commit 1ffbfa5692
6 changed files with 141 additions and 126 deletions

View File

@ -12,7 +12,7 @@ SECRET_KEY = os.getenv('JWT_SECRET', 'django-insecure-fallback-secret-key')
DEBUG = os.getenv('DEBUG') DEBUG = os.getenv('DEBUG')
ALLOWED_HOSTS = os.getenv('ALLOWED_HOSTS', '*').split(',') ALLOWED_HOSTS = ["*"]
CORS_ALLOWED_ORIGINS = os.getenv( CORS_ALLOWED_ORIGINS = os.getenv(
'CORS_ALLOWED_ORIGINS', 'CORS_ALLOWED_ORIGINS',
@ -33,6 +33,7 @@ INSTALLED_APPS = [
'rest_framework', 'rest_framework',
'rest_framework_simplejwt', 'rest_framework_simplejwt',
'corsheaders', 'corsheaders',
'drf_spectacular',
'users', 'users',
'meetings', 'meetings',
@ -142,6 +143,7 @@ DEFAULT_FROM_EMAIL = os.getenv('SMTP_FROM', 'hello@attunehearttherapy.com')
REST_FRAMEWORK = { REST_FRAMEWORK = {
'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema',
'DEFAULT_AUTHENTICATION_CLASSES': ( 'DEFAULT_AUTHENTICATION_CLASSES': (
'rest_framework_simplejwt.authentication.JWTAuthentication', '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 = [ CORS_ALLOWED_ORIGINS = [
"http://localhost:3000", "http://localhost:3000",
"http://127.0.0.1:3000", "http://127.0.0.1:3000",

View File

@ -1,10 +1,15 @@
from django.urls import path, include from django.urls import path, include
from django.contrib import admin from django.contrib import admin
from .views import api_root from .views import api_root
from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView
urlpatterns = [ urlpatterns = [
path('admin/', admin.site.urls), path('admin/', admin.site.urls),
path('api/auth/', include('users.urls')), path('api/auth/', include('users.urls')),
path('api/meetings/', include('meetings.urls')), path('api/meetings/', include('meetings.urls')),
path('', api_root, name='api-root'), 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'),
] ]

View File

@ -4,12 +4,12 @@ from .views import (
AppointmentRequestListView, AppointmentRequestListView,
AppointmentRequestCreateView, AppointmentRequestCreateView,
AppointmentRequestDetailView, AppointmentRequestDetailView,
schedule_appointment, ScheduleAppointmentView,
reject_appointment, RejectAppointmentView,
available_dates, AvailableDatesView,
user_appointments, UserAppointmentsView,
appointment_stats, AppointmentStatsView,
user_apointment_stats UserAppointmentStatsView
) )
urlpatterns = [ urlpatterns = [
@ -19,12 +19,12 @@ urlpatterns = [
path('appointments/create/', AppointmentRequestCreateView.as_view(), name='appointment-create'), path('appointments/create/', AppointmentRequestCreateView.as_view(), name='appointment-create'),
path('appointments/<uuid:pk>/', AppointmentRequestDetailView.as_view(), name='appointment-detail'), path('appointments/<uuid:pk>/', AppointmentRequestDetailView.as_view(), name='appointment-detail'),
path('appointments/<uuid:pk>/schedule/', schedule_appointment, name='appointment-schedule'), path('appointments/<uuid:pk>/schedule/', ScheduleAppointmentView.as_view(), name='appointment-schedule'),
path('appointments/<uuid:pk>/reject/', reject_appointment, name='appointment-reject'), path('appointments/<uuid:pk>/reject/', RejectAppointmentView.as_view(), name='appointment-reject'),
path('appointments/available-dates/', available_dates, name='available-dates'), path('appointments/available-dates/', AvailableDatesView.as_view(), name='available-dates'),
path('user/appointments/', user_appointments, name='user-appointments'), path('user/appointments/', UserAppointmentsView.as_view(), name='user-appointments'),
path('appointments/stats/', appointment_stats, name='appointment-stats'), path('appointments/stats/', AppointmentStatsView.as_view(), name='appointment-stats'),
path('user/appointments/stats/', user_apointment_stats, name='user-appointment-stats'), path('user/appointments/stats/', UserAppointmentStatsView.as_view(), name='user-appointment-stats'),
] ]

View File

@ -1,7 +1,7 @@
from rest_framework import generics, status from rest_framework import generics, status
from rest_framework.decorators import api_view, permission_classes from rest_framework.decorators import api_view, permission_classes
from rest_framework.response import Response 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 django.utils import timezone
from datetime import datetime, timedelta from datetime import datetime, timedelta
from .models import AdminWeeklyAvailability, AppointmentRequest from .models import AdminWeeklyAvailability, AppointmentRequest
@ -14,9 +14,11 @@ from .serializers import (
) )
from .email_service import EmailService from .email_service import EmailService
from users.models import CustomUser from users.models import CustomUser
from django.db.models import Count, Q
class AdminAvailabilityView(generics.RetrieveUpdateAPIView): class AdminAvailabilityView(generics.RetrieveUpdateAPIView):
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated, IsAdminUser]
serializer_class = AdminWeeklyAvailabilitySerializer serializer_class = AdminWeeklyAvailabilitySerializer
def get_object(self): def get_object(self):
@ -63,13 +65,14 @@ class AppointmentRequestDetailView(generics.RetrieveAPIView):
serializer_class = AppointmentRequestSerializer serializer_class = AppointmentRequestSerializer
lookup_field = 'pk' lookup_field = 'pk'
@api_view(['POST']) class ScheduleAppointmentView(generics.GenericAPIView):
@permission_classes([IsAuthenticated]) permission_classes = [IsAuthenticated, IsAdminUser]
def schedule_appointment(request, pk): serializer_class = AppointmentScheduleSerializer
try: queryset = AppointmentRequest.objects.all()
appointment = AppointmentRequest.objects.get(pk=pk) lookup_field = 'pk'
except AppointmentRequest.DoesNotExist:
return Response({'error': 'Appointment not found'}, status=status.HTTP_404_NOT_FOUND) def post(self, request, pk):
appointment = self.get_object()
if appointment.status != 'pending_review': if appointment.status != 'pending_review':
return Response( return Response(
@ -77,8 +80,9 @@ def schedule_appointment(request, pk):
status=status.HTTP_400_BAD_REQUEST status=status.HTTP_400_BAD_REQUEST
) )
serializer = AppointmentScheduleSerializer(data=request.data) serializer = self.get_serializer(data=request.data)
if serializer.is_valid(): serializer.is_valid(raise_exception=True)
appointment.schedule_appointment( appointment.schedule_appointment(
datetime_obj=serializer.validated_data['scheduled_datetime'], datetime_obj=serializer.validated_data['scheduled_datetime'],
duration=serializer.validated_data['scheduled_duration'] duration=serializer.validated_data['scheduled_duration']
@ -93,16 +97,15 @@ def schedule_appointment(request, pk):
'jitsi_meeting_created': appointment.has_jitsi_meeting 'jitsi_meeting_created': appointment.has_jitsi_meeting
}) })
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
class RejectAppointmentView(generics.GenericAPIView):
permission_classes = [IsAuthenticated]
serializer_class = AppointmentRejectSerializer
queryset = AppointmentRequest.objects.all()
lookup_field = 'pk'
@api_view(['POST']) def post(self, request, pk):
@permission_classes([IsAuthenticated]) appointment = self.get_object()
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)
if appointment.status != 'pending_review': if appointment.status != 'pending_review':
return Response( return Response(
@ -110,17 +113,22 @@ def reject_appointment(request, pk):
status=status.HTTP_400_BAD_REQUEST status=status.HTTP_400_BAD_REQUEST
) )
serializer = AppointmentRejectSerializer(data=request.data) serializer = self.get_serializer(data=request.data)
if serializer.is_valid(): serializer.is_valid(raise_exception=True)
appointment.reject_appointment(serializer.validated_data.get('rejection_reason', ''))
appointment.reject_appointment(
serializer.validated_data.get('rejection_reason', '')
)
EmailService.send_appointment_rejected(appointment) EmailService.send_appointment_rejected(appointment)
return Response(AppointmentRequestSerializer(appointment).data)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) response_serializer = AppointmentRequestSerializer(appointment)
return Response(response_serializer.data)
@api_view(['GET'])
@permission_classes([AllowAny]) class AvailableDatesView(generics.GenericAPIView):
def available_dates(request): permission_classes = [AllowAny]
def get(self, request):
availability = AdminWeeklyAvailability.objects.first() availability = AdminWeeklyAvailability.objects.first()
if not availability: if not availability:
return Response([]) return Response([])
@ -136,25 +144,20 @@ def available_dates(request):
return Response(available_dates) return Response(available_dates)
@api_view(['GET']) class UserAppointmentsView(generics.ListAPIView):
@permission_classes([IsAuthenticated]) permission_classes = [IsAuthenticated]
def user_appointments(request): serializer_class = AppointmentRequestSerializer
appointments = AppointmentRequest.objects.filter(
email=request.user.email def get_queryset(self):
return AppointmentRequest.objects.filter(
email=self.request.user.email
).order_by('-created_at') ).order_by('-created_at')
serializer = AppointmentRequestSerializer(appointments, many=True)
return Response(serializer.data)
@api_view(['GET']) class AppointmentStatsView(generics.GenericAPIView):
@permission_classes([IsAuthenticated]) permission_classes = [IsAuthenticated, IsAdminUser]
def appointment_stats(request):
if not request.user.is_staff:
return Response(
{'error': 'Unauthorized'},
status=status.HTTP_403_FORBIDDEN
)
def get(self, request):
total = AppointmentRequest.objects.count() total = AppointmentRequest.objects.count()
users = CustomUser.objects.filter(is_staff=False).count() users = CustomUser.objects.filter(is_staff=False).count()
pending = AppointmentRequest.objects.filter(status='pending_review').count() pending = AppointmentRequest.objects.filter(status='pending_review').count()
@ -170,26 +173,29 @@ def appointment_stats(request):
'completion_rate': round((scheduled / total * 100), 2) if total > 0 else 0 'completion_rate': round((scheduled / total * 100), 2) if total > 0 else 0
}) })
@api_view(['GET']) class UserAppointmentStatsView(generics.GenericAPIView):
@permission_classes([IsAuthenticated]) permission_classes = [IsAuthenticated]
def user_apointment_stats(request):
if not request.user.is_staff: def get(self, request):
return Response( stats = AppointmentRequest.objects.filter(
{'error': 'Unauthorized'}, email=request.user.email
status=status.HTTP_403_FORBIDDEN ).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.filter(email=request.user.email).count() total = stats['total']
pending = AppointmentRequest.objects.filter(email=request.user.email, status='pending_review').count() scheduled = stats['scheduled']
scheduled = AppointmentRequest.objects.filter(email=request.user.email, status='scheduled').count() completion_rate = round((scheduled / total * 100), 2) if total > 0 else 0
rejected = AppointmentRequest.objects.filter(email=request.user.email, status='rejected').count()
completed = AppointmentRequest.objects.filter(email=request.user.email, status='completed').count()
return Response({ return Response({
'total_requests': total, 'total_requests': total,
'pending_review': pending, 'pending_review': stats['pending'],
'scheduled': scheduled, 'scheduled': scheduled,
'rejected': rejected, 'rejected': stats['rejected'],
'completed': completed, 'completed': stats['completed'],
'completion_rate': round((scheduled / total * 100), 2) if total > 0 else 0 'completion_rate': completion_rate
}) })

Binary file not shown.

View File

@ -1,7 +1,7 @@
from rest_framework import status, generics from rest_framework import status, generics
from rest_framework.decorators import api_view, permission_classes from rest_framework.decorators import api_view, permission_classes
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.permissions import AllowAny, IsAuthenticated, IsAdminUser
from rest_framework_simplejwt.tokens import RefreshToken from rest_framework_simplejwt.tokens import RefreshToken
from django.contrib.auth import authenticate from django.contrib.auth import authenticate
from .models import CustomUser, UserProfile from .models import CustomUser, UserProfile
@ -359,10 +359,6 @@ class UserDetailView(generics.RetrieveAPIView):
def get_object(self): def get_object(self):
return self.request.user return self.request.user
def IsAdminUser(user):
return user.is_staff
class GetAllUsersView(generics.ListAPIView): class GetAllUsersView(generics.ListAPIView):
serializer_class = UserSerializer serializer_class = UserSerializer
permission_classes = [IsAdminUser, IsAuthenticated] permission_classes = [IsAdminUser, IsAuthenticated]
@ -389,10 +385,8 @@ class DeleteUserView(generics.DestroyAPIView):
def perform_destroy(self, instance): def perform_destroy(self, instance):
instance.delete() instance.delete()
# Delete associated UserProfile
UserProfile.objects.filter(user=instance).delete() UserProfile.objects.filter(user=instance).delete()
# Delete associated AppointmentRequests
AppointmentRequest.objects.filter(email=instance.email).delete() AppointmentRequest.objects.filter(email=instance.email).delete()
class ActivateOrDeactivateUserView(generics.UpdateAPIView): class ActivateOrDeactivateUserView(generics.UpdateAPIView):