from rest_framework import serializers from .models import AdminWeeklyAvailability, AppointmentRequest, get_admin_availability, check_date_availability from django.utils import timezone from datetime import datetime, timedelta import json class AdminWeeklyAvailabilitySerializer(serializers.ModelSerializer): availability_schedule_display = serializers.SerializerMethodField() all_available_slots = serializers.SerializerMethodField() class Meta: model = AdminWeeklyAvailability fields = [ 'id', 'availability_schedule', 'availability_schedule_display', 'all_available_slots', 'created_at', 'updated_at' ] def get_availability_schedule_display(self, obj): if not obj.availability_schedule: return "No availability set" display = {} days_map = dict(AdminWeeklyAvailability.DAYS_OF_WEEK) time_slots_map = dict(AdminWeeklyAvailability.TIME_SLOT_CHOICES) for day_num, time_slots in obj.availability_schedule.items(): day_name = days_map.get(int(day_num)) slot_names = [time_slots_map.get(slot, slot) for slot in time_slots] display[day_name] = slot_names return display def get_all_available_slots(self, obj): return obj.get_all_available_slots() class AdminWeeklyAvailabilityUpdateSerializer(serializers.ModelSerializer): class Meta: model = AdminWeeklyAvailability fields = ['availability_schedule'] def validate_availability_schedule(self, value): if not isinstance(value, dict): raise serializers.ValidationError("Availability schedule must be a dictionary.") valid_days = [str(day[0]) for day in AdminWeeklyAvailability.DAYS_OF_WEEK] valid_slots = [slot[0] for slot in AdminWeeklyAvailability.TIME_SLOT_CHOICES] for day, time_slots in value.items(): if day not in valid_days: raise serializers.ValidationError(f"Invalid day: {day}. Must be one of {valid_days}.") if not isinstance(time_slots, list): raise serializers.ValidationError(f"Time slots for day {day} must be a list.") for slot in time_slots: if slot not in valid_slots: raise serializers.ValidationError( f"Invalid time slot: {slot} for day {day}. Must be one of {valid_slots}." ) return value class AppointmentRequestSerializer(serializers.ModelSerializer): full_name = serializers.ReadOnlyField() formatted_created_at = serializers.ReadOnlyField() formatted_scheduled_datetime = serializers.ReadOnlyField() preferred_dates_display = serializers.ReadOnlyField() preferred_time_slots_display = serializers.ReadOnlyField() has_jitsi_meeting = serializers.ReadOnlyField() jitsi_meet_url = serializers.ReadOnlyField() jitsi_room_id = serializers.ReadOnlyField() can_join_meeting = serializers.SerializerMethodField() can_join_as_moderator = serializers.SerializerMethodField() can_join_as_participant = serializers.SerializerMethodField() meeting_status = serializers.ReadOnlyField() meeting_duration_display = serializers.ReadOnlyField() matching_availability = serializers.SerializerMethodField() are_preferences_available = serializers.SerializerMethodField() moderator_join_url = serializers.SerializerMethodField() participant_join_url = serializers.SerializerMethodField() meeting_analytics = serializers.SerializerMethodField() selected_slots = serializers.JSONField() class Meta: model = AppointmentRequest fields = [ 'id', 'first_name', 'last_name', 'email', 'phone', 'reason', 'preferred_dates', 'preferred_time_slots', 'selected_slots', 'status', 'scheduled_datetime', 'scheduled_duration', 'rejection_reason', 'jitsi_meet_url', 'jitsi_room_id', 'created_at', 'updated_at', 'full_name', 'formatted_created_at', 'formatted_scheduled_datetime', 'preferred_dates_display', 'preferred_time_slots_display', 'has_jitsi_meeting', 'can_join_meeting', 'can_join_as_moderator', 'can_join_as_participant', 'meeting_status', 'meeting_duration_display', 'matching_availability', 'are_preferences_available', 'moderator_join_url', 'participant_join_url', 'meeting_analytics' ] read_only_fields = [ 'id', 'status', 'scheduled_datetime', 'scheduled_duration', 'rejection_reason', 'jitsi_meet_url', 'jitsi_room_id', 'created_at', 'updated_at', 'preferred_dates', 'preferred_time_slots', 'selected_slots' ] def get_can_join_meeting(self, obj): return obj.can_join_meeting('participant') def get_can_join_as_moderator(self, obj): return obj.can_join_meeting('moderator') def get_can_join_as_participant(self, obj): return obj.can_join_meeting('participant') def get_moderator_join_url(self, obj): request = self.context.get('request') if not request or not request.user.is_authenticated: return None user = request.user is_authorized = ( user.is_staff or (hasattr(user, 'is_therapist') and user.is_therapist) or (hasattr(obj, 'created_by_id') and str(user.id) == str(obj.created_by_id)) ) if is_authorized and obj.has_jitsi_meeting: return obj.get_moderator_join_url(moderator_user=user) return None def get_participant_join_url(self, obj): request = self.context.get('request') if not request or not request.user.is_authenticated: return None if not obj.has_jitsi_meeting or not obj.email: return None # Try to get the participant user from the appointment's email participant_user = None try: from users.models import CustomUser participant_user = CustomUser.objects.get(email=obj.email) except CustomUser.DoesNotExist: # Participant user doesn't exist (not registered) return None # Check if current user is authorized to view this URL current_user = request.user is_authorized = ( current_user.is_staff or # Staff can see participant URLs for all appointments current_user.email == obj.email or # Participant can see their own URL (hasattr(obj, 'invited_participants') and current_user.email in obj.invited_participants) ) if not is_authorized: return None if current_user.is_staff: return obj.get_participant_join_url( participant_user=participant_user, include_password=True ) else: return obj.get_participant_join_url( participant_user=participant_user, include_password=True ) def get_meeting_analytics(self, obj): return { 'scheduled_duration': obj.scheduled_duration, 'actual_duration': obj.meeting_duration_actual if hasattr(obj, 'meeting_duration_actual') else 0, 'started_at': obj.meeting_started_at if hasattr(obj, 'meeting_started_at') else None, 'ended_at': obj.meeting_ended_at if hasattr(obj, 'meeting_ended_at') else None, 'status': obj.status, 'punctuality': self._calculate_punctuality(obj), 'efficiency': self._calculate_efficiency(obj), } def _calculate_punctuality(self, obj): if not hasattr(obj, 'meeting_started_at') or not obj.meeting_started_at: return None if not obj.scheduled_datetime: return None delay = obj.meeting_started_at - obj.scheduled_datetime delay_minutes = delay.total_seconds() / 60 if delay_minutes <= 5: return 'on_time' elif delay_minutes <= 15: return 'slightly_late' else: return 'late' def _calculate_efficiency(self, obj): if not hasattr(obj, 'meeting_started_at') or not hasattr(obj, 'meeting_ended_at'): return None if not obj.meeting_started_at or not obj.meeting_ended_at: return None if not obj.scheduled_duration: return None actual_duration = (obj.meeting_ended_at - obj.meeting_started_at).total_seconds() / 60 scheduled_duration = obj.scheduled_duration if actual_duration <= scheduled_duration: return 'efficient' elif actual_duration <= scheduled_duration * 1.2: return 'slightly_over' else: return 'over_time' def get_matching_availability(self, obj): return obj.get_matching_availability() def get_are_preferences_available(self, obj): return obj.are_preferences_available() class AppointmentRequestCreateSerializer(serializers.ModelSerializer): selected_slots = serializers.ListField( child=serializers.DictField(), required=True, help_text="List of selected day-time combinations: [{'day': 0, 'time_slot': 'morning'}]" ) class Meta: model = AppointmentRequest fields = [ 'first_name', 'last_name', 'email', 'phone', 'reason', 'selected_slots' ] def validate(self, data): selected_slots = data.get('selected_slots') if not selected_slots: raise serializers.ValidationError("At least one time slot must be selected.") # Your existing validation logic availability = get_admin_availability() if not availability: raise serializers.ValidationError("No admin availability set.") for i, slot in enumerate(selected_slots): day = slot.get('day') time_slot = slot.get('time_slot') if day is None or time_slot is None: raise serializers.ValidationError(f"Slot {i+1}: Must have 'day' and 'time_slot'.") if not availability.is_available(day, time_slot): day_name = dict(AdminWeeklyAvailability.DAYS_OF_WEEK).get(day, 'Unknown day') raise serializers.ValidationError( f"Slot {i+1}: '{time_slot}' on {day_name} is not available." ) # Add dates to the slots and store enhanced version enhanced_slots = self._add_dates_to_slots(selected_slots) data['selected_slots'] = enhanced_slots # Calculate preferred_dates and preferred_time_slots for backward compatibility data['preferred_dates'] = self._extract_unique_dates(enhanced_slots) data['preferred_time_slots'] = self._extract_unique_time_slots(enhanced_slots) return data def _add_dates_to_slots(self, selected_slots): """Add actual dates to the slots based on day numbers""" from datetime import datetime today = datetime.now().date() enhanced_slots = [] # We need to find the next occurrence of each day # Keep track of which dates we've assigned to which day day_date_map = {} for slot in selected_slots: day = slot.get('day') time_slot = slot.get('time_slot') # If we haven't assigned a date to this day yet, find the next occurrence if day not in day_date_map: found_date = None for days_ahead in range(1, 15): # Look ahead 2 weeks check_date = today + timedelta(days=days_ahead) if check_date.weekday() == day: found_date = check_date break if found_date: day_date_map[day] = found_date.strftime('%Y-%m-%d') # Add the slot with date if day in day_date_map: enhanced_slots.append({ 'day': day, 'time_slot': time_slot, 'date': day_date_map[day] }) return enhanced_slots def _extract_unique_dates(self, enhanced_slots): """Extract unique dates from enhanced slots""" dates = [] for slot in enhanced_slots: date_str = slot.get('date') if date_str and date_str not in dates: dates.append(date_str) return dates def _extract_unique_time_slots(self, enhanced_slots): """Extract unique time slots from enhanced slots""" time_slots = [] for slot in enhanced_slots: time_slot = slot.get('time_slot') if time_slot and time_slot not in time_slots: time_slots.append(time_slot) return time_slots def create(self, validated_data): # Create the appointment with all data # The selected_slots will be saved to the database automatically return super().create(validated_data) class AppointmentScheduleSerializer(serializers.Serializer): scheduled_datetime = serializers.DateTimeField() scheduled_duration = serializers.IntegerField(default=60, min_value=30, max_value=240) date_str = serializers.CharField(required=False, write_only=True) time_slot = serializers.CharField(required=False, write_only=True) create_jitsi_meeting = serializers.BooleanField(default=True) jitsi_custom_config = serializers.JSONField(required=False, default=dict) def validate(self, data): scheduled_datetime = data.get('scheduled_datetime') date_str = data.get('date_str') time_slot = data.get('time_slot') if date_str and time_slot: try: date_obj = datetime.strptime(date_str, '%Y-%m-%d').date() day_of_week = date_obj.weekday() availability = get_admin_availability() if not availability.is_available(day_of_week, time_slot): raise serializers.ValidationError( f"The admin is not available on {date_obj.strftime('%A')} during the {time_slot} time slot." ) datetime_obj = self._convert_to_datetime(date_obj, time_slot) data['scheduled_datetime'] = datetime_obj except ValueError as e: raise serializers.ValidationError(f"Invalid date format: {e}") if scheduled_datetime and scheduled_datetime <= timezone.now(): raise serializers.ValidationError("Scheduled datetime must be in the future.") return data def _convert_to_datetime(self, date_obj, time_slot): time_mapping = { 'morning': (9, 0), 'afternoon': (13, 0), 'evening': (18, 0) } if time_slot not in time_mapping: raise serializers.ValidationError(f"Invalid time slot: {time_slot}") hour, minute = time_mapping[time_slot] return timezone.make_aware( datetime.combine(date_obj, datetime.min.time().replace(hour=hour, minute=minute)) ) def validate_scheduled_duration(self, value): if value < 30: raise serializers.ValidationError("Duration must be at least 30 minutes.") if value > 240: raise serializers.ValidationError("Duration cannot exceed 4 hours.") return value def save(self, appointment, moderator_user): scheduled_datetime = self.validated_data['scheduled_datetime'] scheduled_duration = self.validated_data.get('scheduled_duration', 60) create_meeting = self.validated_data.get('create_jitsi_meeting', True) custom_config = self.validated_data.get('jitsi_custom_config', {}) appointment.schedule_appointment( datetime_obj=scheduled_datetime, duration=scheduled_duration, create_meeting=False, moderator_user=moderator_user, commit=False ) if create_meeting: if custom_config: appointment.create_jitsi_meeting( moderator_user=moderator_user, with_moderation=True, custom_config=custom_config ) else: appointment.create_jitsi_meeting( moderator_user=moderator_user, with_moderation=True ) appointment.save() return appointment def to_representation(self, instance): representation = { 'id': str(instance.id), 'status': instance.status, 'scheduled_datetime': instance.scheduled_datetime, 'scheduled_duration': instance.scheduled_duration, 'jitsi_meeting_created': instance.jitsi_meeting_created, } if instance.has_jitsi_meeting: representation['jitsi_room_id'] = instance.jitsi_room_id representation['jitsi_meet_url'] = instance.jitsi_meet_url representation['jitsi_meeting_password'] = instance.jitsi_meeting_password return representation class AppointmentDetailSerializer(serializers.ModelSerializer): meeting_info = serializers.SerializerMethodField() meeting_analytics = serializers.SerializerMethodField() can_join_as_moderator = serializers.SerializerMethodField() can_join_as_participant = serializers.SerializerMethodField() moderator_join_url = serializers.SerializerMethodField() participant_join_url = serializers.SerializerMethodField() class Meta: model = AppointmentRequest fields = [ 'id', 'first_name', 'last_name', 'email', 'phone', 'reason', 'preferred_dates', 'preferred_time_slots', 'status', 'scheduled_datetime', 'scheduled_duration', 'rejection_reason', 'jitsi_meet_url', 'jitsi_room_id', 'jitsi_meeting_created', 'meeting_started_at', 'meeting_ended_at', 'meeting_duration_actual', 'created_at', 'updated_at', 'meeting_info', 'meeting_analytics', 'can_join_as_moderator', 'can_join_as_participant', 'moderator_join_url', 'participant_join_url' ] read_only_fields = ['id', 'created_at', 'updated_at'] def get_meeting_info(self, obj): if not obj.has_jitsi_meeting: return None return { 'has_meeting': obj.has_jitsi_meeting, 'meeting_ready': getattr(obj, 'meeting_join_ready', False), 'room_id': obj.jitsi_room_id, 'password_set': bool(obj.jitsi_meeting_password), } def get_meeting_analytics(self, obj): if not obj.has_jitsi_meeting: return None analytics = { 'scheduled_duration': obj.scheduled_duration, 'actual_duration': getattr(obj, 'meeting_duration_actual', 0), 'started_at': getattr(obj, 'meeting_started_at', None), 'ended_at': getattr(obj, 'meeting_ended_at', None), 'status': obj.status, } if hasattr(obj, 'meeting_started_at') and obj.meeting_started_at and obj.scheduled_datetime: delay = obj.meeting_started_at - obj.scheduled_datetime delay_minutes = delay.total_seconds() / 60 if delay_minutes <= 5: analytics['punctuality'] = 'on_time' elif delay_minutes <= 15: analytics['punctuality'] = 'slightly_late' else: analytics['punctuality'] = 'late' else: analytics['punctuality'] = None if (hasattr(obj, 'meeting_started_at') and hasattr(obj, 'meeting_ended_at') and obj.meeting_started_at and obj.meeting_ended_at and obj.scheduled_duration): actual_duration = (obj.meeting_ended_at - obj.meeting_started_at).total_seconds() / 60 scheduled_duration = obj.scheduled_duration if actual_duration <= scheduled_duration: analytics['efficiency'] = 'efficient' elif actual_duration <= scheduled_duration * 1.2: analytics['efficiency'] = 'slightly_over' else: analytics['efficiency'] = 'over_time' else: analytics['efficiency'] = None return analytics def get_can_join_as_moderator(self, obj): return obj.can_join_meeting(user_type='moderator') def get_can_join_as_participant(self, obj): return obj.can_join_meeting(user_type='participant') def get_moderator_join_url(self, obj): request = self.context.get('request') if not request or not request.user.is_authenticated: return None user = request.user is_authorized = ( user.is_staff or (hasattr(user, 'is_therapist') and user.is_therapist) or (hasattr(obj, 'created_by_id') and str(user.id) == str(obj.created_by_id)) ) if is_authorized and obj.has_jitsi_meeting: return obj.get_moderator_join_url(moderator_user=user) return None def get_participant_join_url(self, obj): request = self.context.get('request') if not request or not request.user.is_authenticated: return None if not obj.has_jitsi_meeting or not obj.email: return None participant_user = None try: from users.models import CustomUser participant_user = CustomUser.objects.get(email=obj.email) except CustomUser.DoesNotExist: return None current_user = request.user is_authorized = ( current_user.is_staff or current_user.email == obj.email or (hasattr(obj, 'invited_participants') and current_user.email in obj.invited_participants) ) if not is_authorized: return None if current_user.is_staff: return obj.get_participant_join_url( participant_user=participant_user, include_password=True ) else: return obj.get_participant_join_url( participant_user=participant_user, include_password=True ) class JitsiMeetingSerializer(serializers.ModelSerializer): moderator_join_url = serializers.SerializerMethodField() participant_join_url = serializers.SerializerMethodField() meeting_config = serializers.SerializerMethodField() class Meta: model = AppointmentRequest fields = [ 'id', 'jitsi_meet_url', 'jitsi_room_id', 'jitsi_meeting_created', 'jitsi_meeting_password', 'jitsi_meeting_config', 'moderator_join_url', 'participant_join_url', 'meeting_config', ] read_only_fields = ['id'] def get_moderator_join_url(self, obj): if not obj.has_jitsi_meeting: return None request = self.context.get('request') if request and request.user.is_authenticated and request.user.is_staff: return obj.get_moderator_join_url(moderator_user=request.user) class GenericModerator: id = 'moderator' first_name = 'Moderator' last_name = '' email = '' return obj.get_moderator_join_url(moderator_user=GenericModerator()) def get_participant_join_url(self, obj): if not obj.has_jitsi_meeting: return None request = self.context.get('request') participant_user = None if obj.email: try: from users.models import CustomUser participant_user = CustomUser.objects.get(email=obj.email) except CustomUser.DoesNotExist: pass if not participant_user: class GenericParticipant: id = 'participant' first_name = 'Participant' last_name = '' email = obj.email if obj.email else '' participant_user = GenericParticipant() return obj.get_participant_join_url(participant_user=participant_user) def get_meeting_config(self, obj): if not obj.jitsi_meeting_config: return {} return obj.jitsi_meeting_config def update(self, instance, validated_data): if not instance.has_jitsi_meeting: custom_config = validated_data.get('jitsi_meeting_config', {}) instance.create_jitsi_meeting( with_moderation=True, custom_config=custom_config ) return super().update(instance, validated_data) class MeetingJoinSerializer(serializers.Serializer): user_type = serializers.ChoiceField( choices=['moderator', 'participant'], default='participant' ) token = serializers.CharField(required=False, allow_blank=True) def validate(self, data): appointment = self.context.get('appointment') if not appointment: raise serializers.ValidationError("Appointment context required") user_type = data.get('user_type') token = data.get('token') if user_type == 'participant' and token: expected_token = appointment.jitsi_participant_token if expected_token and not expected_token.startswith(token[:20]): raise serializers.ValidationError("Invalid join token") if not appointment.can_join_meeting(user_type): raise serializers.ValidationError( f"Cannot join meeting as {user_type}. " f"Meeting window is not open or meeting has ended." ) return data class MeetingActionSerializer(serializers.Serializer): action = serializers.ChoiceField( choices=['start', 'end', 'update_metadata', 'record', 'allow_participants', 'disallow_participants'] ) metadata = serializers.JSONField(required=False, default=dict) recording_url = serializers.URLField(required=False, allow_blank=True) def validate(self, data): appointment = self.context.get('appointment') if not appointment: raise serializers.ValidationError("Appointment context required") action = data.get('action') if action == 'start' and appointment.meeting_started_at: raise serializers.ValidationError("Meeting already started") if action == 'end' and appointment.meeting_ended_at: raise serializers.ValidationError("Meeting already ended") if action == 'end' and not appointment.meeting_started_at: raise serializers.ValidationError("Meeting not started yet") return data class AppointmentRejectSerializer(serializers.Serializer): rejection_reason = serializers.CharField(required=False, allow_blank=True) class AvailabilityCheckSerializer(serializers.Serializer): date = serializers.CharField(required=True) def validate_date(self, value): try: date_obj = datetime.strptime(value, '%Y-%m-%d').date() if date_obj < timezone.now().date(): raise serializers.ValidationError("Date cannot be in the past.") return value except ValueError: raise serializers.ValidationError("Invalid date format. Use YYYY-MM-DD.") class AvailabilityResponseSerializer(serializers.Serializer): date = serializers.CharField() day_name = serializers.CharField() available_slots = serializers.ListField(child=serializers.CharField()) available_slots_display = serializers.ListField(child=serializers.CharField()) is_available = serializers.BooleanField() class WeeklyAvailabilitySerializer(serializers.Serializer): day_number = serializers.IntegerField() day_name = serializers.CharField() available_slots = serializers.ListField(child=serializers.CharField()) available_slots_display = serializers.ListField(child=serializers.CharField()) class TimeSlotSerializer(serializers.Serializer): value = serializers.CharField() display = serializers.CharField() disabled = serializers.BooleanField(default=False) class DayAvailabilitySerializer(serializers.Serializer): day_number = serializers.IntegerField() day_name = serializers.CharField() time_slots = TimeSlotSerializer(many=True) is_available = serializers.BooleanField() class AdminAvailabilityConfigSerializer(serializers.Serializer): days = DayAvailabilitySerializer(many=True) @classmethod def get_default_config(cls): days_config = [] for day_num, day_name in AdminWeeklyAvailability.DAYS_OF_WEEK: days_config.append({ 'day_number': day_num, 'day_name': day_name, 'time_slots': [ { 'value': 'morning', 'display': 'Morning (9AM - 12PM)', 'disabled': False }, { 'value': 'afternoon', 'display': 'Afternoon (1PM - 5PM)', 'disabled': False }, { 'value': 'evening', 'display': 'Evening (6PM - 9PM)', 'disabled': False } ], 'is_available': False }) return cls({'days': days_config})