Compare commits

...

2 Commits

Author SHA1 Message Date
4b86761ddc Merge pull request 'feat: add API documentation with drf-spectacular and refactor views' (#31) from feature/meetings into main
Reviewed-on: https://gitea.blackbusinesslabs.com/ATTUNE-HEART-THERAPY/alternative-backend-service/pulls/31
2025-11-24 13:31:17 +00:00
1ffbfa5692 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.
2025-11-24 13:29:07 +00:00
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,22 +65,24 @@ 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)
if appointment.status != 'pending_review': def post(self, request, pk):
return Response( appointment = self.get_object()
{'error': 'Only pending review appointments can be scheduled.'},
status=status.HTTP_400_BAD_REQUEST if appointment.status != 'pending_review':
) return Response(
{'error': 'Only pending review appointments can be scheduled.'},
serializer = AppointmentScheduleSerializer(data=request.data) status=status.HTTP_400_BAD_REQUEST
if serializer.is_valid(): )
serializer = self.get_serializer(data=request.data)
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']
@ -92,104 +96,106 @@ def schedule_appointment(request, pk):
'message': 'Appointment scheduled successfully. Jitsi meeting created.', 'message': 'Appointment scheduled successfully. Jitsi meeting created.',
'jitsi_meeting_created': appointment.has_jitsi_meeting 'jitsi_meeting_created': appointment.has_jitsi_meeting
}) })
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@api_view(['POST']) class RejectAppointmentView(generics.GenericAPIView):
@permission_classes([IsAuthenticated]) permission_classes = [IsAuthenticated]
def reject_appointment(request, pk): serializer_class = AppointmentRejectSerializer
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)
if appointment.status != 'pending_review': def post(self, request, pk):
return Response( appointment = self.get_object()
{'error': 'Only pending appointments can be rejected'},
status=status.HTTP_400_BAD_REQUEST 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) 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']) class AvailableDatesView(generics.GenericAPIView):
@permission_classes([AllowAny]) permission_classes = [AllowAny]
def available_dates(request):
availability = AdminWeeklyAvailability.objects.first()
if not availability:
return Response([])
available_days = availability.available_days def get(self, request):
today = timezone.now().date() availability = AdminWeeklyAvailability.objects.first()
available_dates = [] if not availability:
return Response([])
for i in range(1, 31):
date = today + timedelta(days=i) available_days = availability.available_days
if date.weekday() in available_days: today = timezone.now().date()
available_dates.append(date.strftime('%Y-%m-%d')) available_dates = []
return Response(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']) 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):
).order_by('-created_at') 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']) class AppointmentStatsView(generics.GenericAPIView):
@permission_classes([IsAuthenticated]) permission_classes = [IsAuthenticated, IsAdminUser]
def appointment_stats(request):
if not request.user.is_staff: def get(self, request):
return Response( total = AppointmentRequest.objects.count()
{'error': 'Unauthorized'}, users = CustomUser.objects.filter(is_staff=False).count()
status=status.HTTP_403_FORBIDDEN 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() total = stats['total']
users = CustomUser.objects.filter(is_staff=False).count() scheduled = stats['scheduled']
pending = AppointmentRequest.objects.filter(status='pending_review').count() completion_rate = round((scheduled / total * 100), 2) if total > 0 else 0
scheduled = AppointmentRequest.objects.filter(status='scheduled').count()
rejected = AppointmentRequest.objects.filter(status='rejected').count() return Response({
'total_requests': total,
return Response({ 'pending_review': stats['pending'],
'total_requests': total, 'scheduled': scheduled,
'pending_review': pending, 'rejected': stats['rejected'],
'scheduled': scheduled, 'completed': stats['completed'],
'rejected': rejected, 'completion_rate': completion_rate
'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
})

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):