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')
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",

View File

@ -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'),
]

View File

@ -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/<uuid:pk>/', AppointmentRequestDetailView.as_view(), name='appointment-detail'),
path('appointments/<uuid:pk>/schedule/', schedule_appointment, name='appointment-schedule'),
path('appointments/<uuid:pk>/reject/', reject_appointment, name='appointment-reject'),
path('appointments/<uuid:pk>/schedule/', ScheduleAppointmentView.as_view(), name='appointment-schedule'),
path('appointments/<uuid:pk>/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'),
]

View File

@ -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
})

Binary file not shown.

View File

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