alternative-backend-service/meetings/serializers.py

761 lines
30 KiB
Python

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
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
)
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'}]"
)
timezone = serializers.CharField(
required=False,
default='UTC',
help_text="User's timezone (e.g., 'America/New_York', 'Africa/Accra')"
)
class Meta:
model = AppointmentRequest
fields = [
'first_name', 'last_name', 'email', 'phone', 'reason',
'selected_slots', 'timezone'
]
def validate_timezone(self, value):
"""Validate that the timezone string is valid"""
try:
from zoneinfo import ZoneInfo
ZoneInfo(value)
return value
except Exception:
# If invalid, default to UTC but don't raise error
return 'UTC'
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.")
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."
)
enhanced_slots = self._add_dates_to_slots(selected_slots)
data['selected_slots'] = enhanced_slots
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):
from datetime import datetime
today = datetime.now().date()
enhanced_slots = []
day_date_map = {}
for slot in selected_slots:
day = slot.get('day')
time_slot = slot.get('time_slot')
if day not in day_date_map:
found_date = None
for days_ahead in range(1, 15):
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')
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):
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):
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):
# Extract timezone before creating
timezone = validated_data.pop('timezone', 'UTC')
# Create appointment
appointment = super().create(validated_data)
# Set timezone on the created appointment
appointment.user_timezone = timezone
appointment.save(update_fields=['user_timezone'])
return appointment
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,
'user_timezone': instance.user_timezone,
}
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})