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
This commit is contained in:
Saani 2025-11-24 13:31:17 +00:00
commit 4b86761ddc
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.'},
status=status.HTTP_400_BAD_REQUEST
)
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
serializer = AppointmentScheduleSerializer(data=request.data)
if serializer.is_valid():
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,103 +97,105 @@ 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(
{'error': 'Only pending appointments can be rejected'}, {'error': 'Only pending appointments can be rejected'},
status=status.HTTP_400_BAD_REQUEST 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)
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])
def available_dates(request):
availability = AdminWeeklyAvailability.objects.first()
if not availability:
return Response([])
available_days = availability.available_days class AvailableDatesView(generics.GenericAPIView):
today = timezone.now().date() permission_classes = [AllowAny]
available_dates = []
for i in range(1, 31): def get(self, request):
date = today + timedelta(days=i) availability = AdminWeeklyAvailability.objects.first()
if date.weekday() in available_days: if not availability:
available_dates.append(date.strftime('%Y-%m-%d')) return Response([])
return Response(available_dates) available_days = availability.available_days
today = timezone.now().date()
available_dates = []
@api_view(['GET']) for i in range(1, 31):
@permission_classes([IsAuthenticated]) date = today + timedelta(days=i)
def user_appointments(request): if date.weekday() in available_days:
appointments = AppointmentRequest.objects.filter( available_dates.append(date.strftime('%Y-%m-%d'))
email=request.user.email
).order_by('-created_at')
serializer = AppointmentRequestSerializer(appointments, many=True) return Response(available_dates)
return Response(serializer.data)
@api_view(['GET']) class UserAppointmentsView(generics.ListAPIView):
@permission_classes([IsAuthenticated]) permission_classes = [IsAuthenticated]
def appointment_stats(request): serializer_class = AppointmentRequestSerializer
if not request.user.is_staff:
return Response( def get_queryset(self):
{'error': 'Unauthorized'}, return AppointmentRequest.objects.filter(
status=status.HTTP_403_FORBIDDEN email=self.request.user.email
).order_by('-created_at')
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() 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({ return Response({
'total_requests': total, 'total_requests': total,
'pending_review': pending, 'pending_review': stats['pending'],
'scheduled': scheduled, 'scheduled': scheduled,
'rejected': rejected, 'rejected': stats['rejected'],
'users': users, 'completed': stats['completed'],
'completion_rate': round((scheduled / total * 100), 2) if total > 0 else 0 'completion_rate': completion_rate
}) })
@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):