alternative-backend-service/meetings/views.py

780 lines
30 KiB
Python

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, IsAdminUser
from rest_framework.views import APIView
from django.utils import timezone
from datetime import datetime, timedelta
from .models import AdminWeeklyAvailability, AppointmentRequest, get_admin_availability, set_admin_availability, get_available_slots_for_week, check_date_availability
from .serializers import (
AdminWeeklyAvailabilitySerializer,
AdminWeeklyAvailabilityUpdateSerializer,
AppointmentRequestSerializer,
AppointmentRequestCreateSerializer,
AppointmentScheduleSerializer,
AppointmentRejectSerializer,
AvailabilityCheckSerializer,
AvailabilityResponseSerializer,
WeeklyAvailabilitySerializer,
AdminAvailabilityConfigSerializer,
AppointmentDetailSerializer,
JitsiMeetingSerializer,
MeetingJoinSerializer,
MeetingActionSerializer
)
from .email_service import EmailService
from users.models import CustomUser
from django.db.models import Count, Q
import hashlib
class AdminAvailabilityView(generics.RetrieveUpdateAPIView):
permission_classes = [IsAuthenticated, IsAdminUser]
def get_serializer_class(self):
if self.request.method == 'GET':
return AdminWeeklyAvailabilitySerializer
return AdminWeeklyAvailabilityUpdateSerializer
def get_object(self):
return get_admin_availability()
def update(self, request, *args, **kwargs):
response = super().update(request, *args, **kwargs)
availability = self.get_object()
full_serializer = AdminWeeklyAvailabilitySerializer(availability)
return Response(full_serializer.data)
class AdminAvailabilityConfigView(generics.GenericAPIView):
permission_classes = [AllowAny]
def get(self, request):
"""Get availability configuration"""
config = AdminAvailabilityConfigSerializer.get_default_config()
return Response(config.data)
class AppointmentRequestListView(generics.ListAPIView):
serializer_class = AppointmentRequestSerializer
permission_classes = [IsAuthenticated]
def get_queryset(self):
queryset = AppointmentRequest.objects.all()
if self.request.user.is_staff:
return queryset
return queryset.filter(email=self.request.user.email)
class AppointmentRequestCreateView(generics.CreateAPIView):
permission_classes = [IsAuthenticated]
queryset = AppointmentRequest.objects.all()
serializer_class = AppointmentRequestCreateSerializer
def perform_create(self, serializer):
appointment = serializer.save()
if appointment.are_preferences_available():
EmailService.send_admin_notification(appointment)
else:
EmailService.send_admin_notification(appointment, availability_mismatch=True)
class AppointmentRequestDetailView(generics.RetrieveAPIView):
permission_classes = [IsAuthenticated]
queryset = AppointmentRequest.objects.all()
serializer_class = AppointmentDetailSerializer
lookup_field = 'pk'
class ScheduleAppointmentView(generics.GenericAPIView):
permission_classes = [IsAuthenticated, IsAdminUser]
serializer_class = AppointmentScheduleSerializer
queryset = AppointmentRequest.objects.all()
lookup_field = 'pk'
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)
create_jitsi_meeting = serializer.validated_data.get('create_jitsi_meeting', True)
jitsi_custom_config = serializer.validated_data.get('jitsi_custom_config', {})
admin_user = request.user
appointment.schedule_appointment(
datetime_obj=serializer.validated_data['scheduled_datetime'],
duration=serializer.validated_data['scheduled_duration'],
create_meeting=create_jitsi_meeting,
moderator_user=admin_user,
commit=False
)
if jitsi_custom_config and create_jitsi_meeting:
if appointment.has_jitsi_meeting:
appointment.jitsi_meeting_config.update(jitsi_custom_config)
else:
appointment.create_jitsi_meeting(
moderator_user=admin_user,
with_moderation=True,
custom_config=jitsi_custom_config
)
appointment.save()
EmailService.send_appointment_scheduled(appointment)
response_serializer = AppointmentDetailSerializer(appointment)
# Try to get participant user from appointment email
participant_user = None
if appointment.email:
try:
participant_user = CustomUser.objects.get(email=appointment.email)
except CustomUser.DoesNotExist:
participant_user = None
return Response({
**response_serializer.data,
'message': 'Appointment scheduled successfully.',
'jitsi_meeting_created': appointment.has_jitsi_meeting,
'moderator_name': admin_user.get_full_name() or admin_user.username,
'moderator_join_url': appointment.get_moderator_join_url(
moderator_user=admin_user
) if appointment.has_jitsi_meeting else None,
'participant_join_url': appointment.get_participant_join_url(
participant_user=participant_user
) if appointment.has_jitsi_meeting and participant_user else None,
})
class RejectAppointmentView(generics.GenericAPIView):
permission_classes = [IsAuthenticated]
serializer_class = AppointmentRejectSerializer
queryset = AppointmentRequest.objects.all()
lookup_field = 'pk'
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', '')
)
EmailService.send_appointment_rejected(appointment)
response_serializer = AppointmentDetailSerializer(appointment)
return Response(response_serializer.data)
class AvailableDatesView(generics.GenericAPIView):
permission_classes = [AllowAny]
def get(self, request):
availability = get_admin_availability()
if not availability or not availability.availability_schedule:
return Response([])
today = timezone.now().date()
available_dates = []
for i in range(1, 31):
date = today + timedelta(days=i)
day_of_week = date.weekday()
available_slots = availability.get_availability_for_day(day_of_week)
if available_slots:
available_dates.append({
'date': date.strftime('%Y-%m-%d'),
'day_name': date.strftime('%A'),
'available_slots': available_slots,
'available_slots_display': [
dict(AdminWeeklyAvailability.TIME_SLOT_CHOICES).get(slot, slot)
for slot in available_slots
]
})
return Response(available_dates)
class CheckDateAvailabilityView(generics.GenericAPIView):
permission_classes = [AllowAny]
serializer_class = AvailabilityCheckSerializer
def post(self, request):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
date_str = serializer.validated_data['date']
available_slots = check_date_availability(date_str)
try:
date_obj = datetime.strptime(date_str, '%Y-%m-%d').date()
day_name = date_obj.strftime('%A')
response_data = {
'date': date_str,
'day_name': day_name,
'available_slots': available_slots,
'available_slots_display': [
dict(AdminWeeklyAvailability.TIME_SLOT_CHOICES).get(slot, slot)
for slot in available_slots
],
'is_available': len(available_slots) > 0
}
return Response(response_data)
except ValueError:
return Response(
{'error': 'Invalid date format'},
status=status.HTTP_400_BAD_REQUEST
)
class WeeklyAvailabilityView(generics.GenericAPIView):
permission_classes = [AllowAny]
def get(self, request):
availability = get_admin_availability()
if not availability:
return Response([])
weekly_availability = []
for day_num, day_name in AdminWeeklyAvailability.DAYS_OF_WEEK:
available_slots = availability.get_availability_for_day(day_num)
weekly_availability.append({
'day_number': day_num,
'day_name': day_name,
'available_slots': available_slots,
'available_slots_display': [
dict(AdminWeeklyAvailability.TIME_SLOT_CHOICES).get(slot, slot)
for slot in available_slots
],
'is_available': len(available_slots) > 0
})
return Response(weekly_availability)
class UserAppointmentsView(generics.ListAPIView):
permission_classes = [IsAuthenticated]
serializer_class = AppointmentDetailSerializer
def get_queryset(self):
user_email = self.request.user.email.lower()
all_appointments = list(AppointmentRequest.objects.all())
matching_appointments = [
apt for apt in all_appointments
if apt.email and apt.email.lower() == user_email
]
appointment_ids = [apt.id for apt in matching_appointments]
return AppointmentRequest.objects.filter(
id__in=appointment_ids
).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()
completed = AppointmentRequest.objects.filter(status='completed').count()
jitsi_meetings = AppointmentRequest.objects.filter(jitsi_meeting_created=True).count()
active_meetings = AppointmentRequest.objects.filter(
status='scheduled',
scheduled_datetime__gt=timezone.now()
).count()
availability = get_admin_availability()
availability_coverage = 0
if availability and availability.availability_schedule:
days_with_availability = len(availability.availability_schedule)
availability_coverage = round((days_with_availability / 7) * 100, 2)
return Response({
'total_requests': total,
'pending_review': pending,
'scheduled': scheduled,
'rejected': rejected,
'completed': completed,
'users': users,
'completion_rate': round((scheduled / total * 100), 2) if total > 0 else 0,
'availability_coverage': availability_coverage,
'available_days_count': days_with_availability if availability else 0,
'jitsi_meetings_created': jitsi_meetings,
'active_upcoming_meetings': active_meetings,
'meetings_with_video': round((jitsi_meetings / total * 100), 2) if total > 0 else 0,
})
class UserAppointmentStatsView(generics.GenericAPIView):
permission_classes = [IsAuthenticated]
serializer_class = AppointmentDetailSerializer
def get_queryset(self):
user_email = self.request.user.email.lower()
all_appointments = list(AppointmentRequest.objects.all())
matching_appointments = [
apt for apt in all_appointments
if apt.email and apt.email.lower() == user_email
]
appointment_ids = [apt.id for apt in matching_appointments]
return AppointmentRequest.objects.filter(
id__in=appointment_ids
)
def get(self, request, *args, **kwargs):
queryset = self.get_queryset()
stats = {
'total': queryset.count(),
'pending': queryset.filter(status='pending_review').count(),
'scheduled': queryset.filter(status='scheduled').count(),
'rejected': queryset.filter(status='rejected').count(),
'completed': queryset.filter(status='completed').count(),
'video_meetings': queryset.filter(jitsi_meeting_created=True).count(),
}
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'],
'video_meetings': stats['video_meetings'],
'completion_rate': completion_rate,
'email': request.user.email,
'upcoming_video_sessions': queryset.filter(
status='scheduled',
jitsi_meeting_created=True,
scheduled_datetime__gt=timezone.now()
).count(),
})
class MatchingAvailabilityView(generics.GenericAPIView):
permission_classes = [IsAuthenticated]
def get(self, request, pk):
try:
appointment = AppointmentRequest.objects.get(pk=pk)
if not request.user.is_staff and appointment.email != request.user.email:
return Response(
{'error': 'You can only view your own appointments'},
status=status.HTTP_403_FORBIDDEN
)
matching_slots = appointment.get_matching_availability()
return Response({
'appointment_id': str(appointment.id),
'preferences_match_availability': appointment.are_preferences_available(),
'matching_slots': matching_slots,
'total_matching_slots': len(matching_slots)
})
except AppointmentRequest.DoesNotExist:
return Response(
{'error': 'Appointment not found'},
status=status.HTTP_404_NOT_FOUND
)
class JoinMeetingView(generics.GenericAPIView):
permission_classes = [IsAuthenticated]
serializer_class = MeetingJoinSerializer
queryset = AppointmentRequest.objects.all()
lookup_field = 'pk'
def get(self, request, pk):
appointment = self.get_object()
# Check permissions
if not self._has_join_permission(request.user, appointment):
return Response(
{'error': 'You do not have permission to join this meeting'},
status=status.HTTP_403_FORBIDDEN
)
user_type = 'moderator' if request.user.is_staff else 'participant'
join_info = appointment.get_meeting_join_info(user_type)
return Response({
'appointment_id': str(appointment.id),
'user_type': user_type,
'user_name': request.user.get_full_name() or request.user.email,
'meeting_info': join_info,
'can_join_now': appointment.can_join_meeting(user_type),
'is_admin_join': appointment.is_admin_join,
'participant_join_enabled': appointment.is_admin_join if user_type == 'participant' else None,
})
def post(self, request, pk):
appointment = self.get_object()
if not self._has_join_permission(request.user, appointment):
return Response(
{'error': 'You do not have permission to join this meeting'},
status=status.HTTP_403_FORBIDDEN
)
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
user_type = serializer.validated_data.get('user_type')
if request.user.is_staff and user_type != 'moderator':
user_type = 'moderator'
elif not request.user.is_staff and user_type == 'moderator':
return Response(
{'error': 'Only staff members can join as moderators'},
status=status.HTTP_403_FORBIDDEN
)
if user_type == 'moderator':
join_url = appointment.get_moderator_join_url(moderator_user=request.user)
else:
# Check if admin has joined and enabled participant joining
if not appointment.is_admin_join:
return Response(
{'error': 'Participants cannot join yet. Please wait for the admin to join and enable participant access.'},
status=status.HTTP_403_FORBIDDEN
)
join_url = appointment.get_participant_join_url(participant_user=request.user)
if not appointment.meeting_started_at and appointment.can_join_meeting(user_type):
appointment.start_meeting()
return Response({
'appointment_id': str(appointment.id),
'join_url': join_url,
'room_id': appointment.jitsi_room_id,
'user_type': user_type,
'password': appointment.jitsi_meeting_password if user_type == 'participant' else None,
'meeting_started': appointment.meeting_started_at is not None,
'join_instructions': self._get_join_instructions(user_type),
})
def _has_join_permission(self, user, appointment):
if user.is_staff:
return True
if appointment.email and appointment.email.lower() == user.email.lower():
return True
return False
def _get_join_instructions(self, user_type):
if user_type == 'moderator':
return {
'title': 'Join as Therapist/Moderator',
'instructions': [
'Click the join link above',
'You have full control over the meeting room',
'You can mute participants, lock the room, and manage recordings',
'Please join 15 minutes before the scheduled time',
'Wait for your patient in the lobby if enabled'
]
}
else:
return {
'title': 'Join as Patient/Participant',
'instructions': [
'Click the join link above',
'Enter your name when prompted',
'You may need to wait in the lobby until the therapist admits you',
'Please join 5 minutes before the scheduled time',
'Ensure you have a stable internet connection'
]
}
class MeetingActionView(generics.GenericAPIView):
permission_classes = [IsAuthenticated, IsAdminUser]
serializer_class = MeetingActionSerializer
queryset = AppointmentRequest.objects.all()
lookup_field = 'pk'
def post(self, request, pk):
appointment = self.get_object()
if appointment.status != 'scheduled':
return Response(
{'error': 'Meeting actions only available for scheduled appointments'},
status=status.HTTP_400_BAD_REQUEST
)
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
action = serializer.validated_data['action']
if action == 'start':
appointment.start_meeting()
message = 'Meeting started successfully'
elif action == 'end':
appointment.end_meeting()
message = 'Meeting ended successfully'
elif action == 'update_metadata':
metadata = serializer.validated_data.get('metadata', {})
appointment.update_meeting_data(metadata)
message = 'Meeting metadata updated successfully'
elif action == 'record':
recording_url = serializer.validated_data.get('recording_url', '')
if recording_url:
appointment.jitsi_recording_url = recording_url
appointment.save(update_fields=['jitsi_recording_url'])
message = 'Recording URL saved successfully'
else:
return Response(
{'error': 'Recording URL is required for record action'},
status=status.HTTP_400_BAD_REQUEST
)
elif action == 'allow_participants':
appointment.is_admin_join = True
appointment.save(update_fields=['is_admin_join', 'updated_at'])
message = 'Participants can now join the meeting'
elif action == 'disallow_participants':
appointment.is_admin_join = False
appointment.save(update_fields=['is_admin_join', 'updated_at'])
message = 'Participants are no longer allowed to join the meeting'
return Response({
'appointment_id': str(appointment.id),
'action': action,
'message': message,
'meeting_status': appointment.status,
'started_at': appointment.meeting_started_at,
'ended_at': appointment.meeting_ended_at,
'is_admin_join': appointment.is_admin_join,
})
class UpcomingMeetingsView(generics.ListAPIView):
permission_classes = [IsAuthenticated]
serializer_class = AppointmentDetailSerializer
def get_queryset(self):
queryset = AppointmentRequest.objects.filter(
status='scheduled',
scheduled_datetime__gt=timezone.now()
).order_by('scheduled_datetime')
if not self.request.user.is_staff:
user_email = self.request.user.email.lower()
all_appointments = list(queryset)
matching_appointments = [
apt for apt in all_appointments
if apt.email and apt.email.lower() == user_email
]
appointment_ids = [apt.id for apt in matching_appointments]
queryset = queryset.filter(id__in=appointment_ids)
return queryset
def list(self, request, *args, **kwargs):
response = super().list(request, *args, **kwargs)
queryset = self.get_queryset()
now = timezone.now()
upcoming_meetings = []
for meeting in queryset:
meeting_data = {
'id': str(meeting.id),
'title': f"Session with {meeting.full_name}",
'start': meeting.scheduled_datetime.isoformat(),
'end': (meeting.scheduled_datetime + timedelta(minutes=meeting.scheduled_duration)).isoformat(),
'can_join': meeting.can_join_meeting('moderator' if request.user.is_staff else 'participant'),
'has_video': meeting.has_jitsi_meeting,
'status': meeting.get_meeting_status(),
}
upcoming_meetings.append(meeting_data)
response.data = {
'upcoming_meetings': upcoming_meetings,
'total_upcoming': queryset.count(),
'next_meeting': upcoming_meetings[0] if upcoming_meetings else None,
'now': now.isoformat(),
}
return response
class MeetingAnalyticsView(generics.RetrieveAPIView):
permission_classes = [IsAuthenticated, IsAdminUser]
queryset = AppointmentRequest.objects.all()
serializer_class = AppointmentDetailSerializer
lookup_field = 'pk'
def retrieve(self, request, *args, **kwargs):
appointment = self.get_object()
analytics = appointment.get_meeting_analytics()
return Response({
'appointment_id': str(appointment.id),
'patient_name': appointment.full_name,
'analytics': analytics,
'video_meeting_info': {
'has_meeting': appointment.has_jitsi_meeting,
'room_id': appointment.jitsi_room_id,
'meeting_created': appointment.jitsi_meeting_created,
'recording_available': bool(appointment.jitsi_recording_url),
},
'timeline': {
'created': appointment.created_at.isoformat(),
'scheduled': appointment.scheduled_datetime.isoformat() if appointment.scheduled_datetime else None,
'started': appointment.meeting_started_at.isoformat() if appointment.meeting_started_at else None,
'ended': appointment.meeting_ended_at.isoformat() if appointment.meeting_ended_at else None,
}
})
class BulkMeetingActionsView(generics.GenericAPIView):
permission_classes = [IsAuthenticated, IsAdminUser]
def post(self, request):
action = request.data.get('action')
appointment_ids = request.data.get('appointment_ids', [])
if not action or not appointment_ids:
return Response(
{'error': 'Action and appointment_ids are required'},
status=status.HTTP_400_BAD_REQUEST
)
valid_actions = ['create_jitsi_meetings', 'send_reminders', 'end_old_meetings']
if action not in valid_actions:
return Response(
{'error': f'Invalid action. Must be one of: {valid_actions}'},
status=status.HTTP_400_BAD_REQUEST
)
appointments = AppointmentRequest.objects.filter(
id__in=appointment_ids,
status='scheduled'
)
results = []
if action == 'create_jitsi_meetings':
for appointment in appointments:
if not appointment.has_jitsi_meeting:
appointment.create_jitsi_meeting()
results.append({
'id': str(appointment.id),
'action': 'meeting_created',
'success': True,
'message': f'Jitsi meeting created for {appointment.full_name}',
})
else:
results.append({
'id': str(appointment.id),
'action': 'already_created',
'success': True,
'message': f'Jitsi meeting already exists for {appointment.full_name}',
})
elif action == 'send_reminders':
for appointment in appointments:
if appointment.has_jitsi_meeting and appointment.meeting_in_future:
EmailService.send_meeting_reminder(appointment)
results.append({
'id': str(appointment.id),
'action': 'reminder_sent',
'success': True,
'message': f'Reminder sent to {appointment.full_name}',
})
elif action == 'end_old_meetings':
for appointment in appointments:
if appointment.meeting_started_at and not appointment.meeting_ended_at:
scheduled_end = appointment.scheduled_datetime + timedelta(minutes=appointment.scheduled_duration)
buffer_end = scheduled_end + timedelta(minutes=30)
if timezone.now() > buffer_end:
appointment.end_meeting()
results.append({
'id': str(appointment.id),
'action': 'meeting_ended',
'success': True,
'message': f'Meeting ended for {appointment.full_name}',
})
return Response({
'action': action,
'total_processed': len(results),
'results': results,
})
@api_view(['GET'])
@permission_classes([AllowAny])
def availability_overview(request):
availability = get_admin_availability()
if not availability:
return Response({
'available': False,
'message': 'No availability set'
})
all_slots = availability.get_all_available_slots()
return Response({
'available': len(all_slots) > 0,
'total_available_slots': len(all_slots),
'available_days': list(set(slot['day_name'] for slot in all_slots)),
'next_available_dates': get_next_available_dates(7)
})
def get_next_available_dates(days_count=7):
availability = get_admin_availability()
if not availability:
return []
today = timezone.now().date()
next_dates = []
for i in range(1, days_count + 1):
date = today + timedelta(days=i)
day_of_week = date.weekday()
available_slots = availability.get_availability_for_day(day_of_week)
if available_slots:
next_dates.append({
'date': date.strftime('%Y-%m-%d'),
'day_name': date.strftime('%A'),
'available_slots': available_slots
})
return next_dates