|
|
|
|
@ -80,11 +80,14 @@ class AppointmentRequestSerializer(serializers.ModelSerializer):
|
|
|
|
|
participant_join_url = serializers.SerializerMethodField()
|
|
|
|
|
meeting_analytics = serializers.SerializerMethodField()
|
|
|
|
|
|
|
|
|
|
# Add selected_slots field
|
|
|
|
|
selected_slots = serializers.JSONField()
|
|
|
|
|
|
|
|
|
|
class Meta:
|
|
|
|
|
model = AppointmentRequest
|
|
|
|
|
fields = [
|
|
|
|
|
'id', 'first_name', 'last_name', 'email', 'phone', 'reason',
|
|
|
|
|
'preferred_dates', 'preferred_time_slots', 'status',
|
|
|
|
|
'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',
|
|
|
|
|
@ -92,12 +95,13 @@ class AppointmentRequestSerializer(serializers.ModelSerializer):
|
|
|
|
|
'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', 'is_admin_join'
|
|
|
|
|
'meeting_analytics'
|
|
|
|
|
]
|
|
|
|
|
read_only_fields = [
|
|
|
|
|
'id', 'status', 'scheduled_datetime', 'scheduled_duration',
|
|
|
|
|
'rejection_reason', 'jitsi_meet_url', 'jitsi_room_id',
|
|
|
|
|
'created_at', 'updated_at',
|
|
|
|
|
'created_at', 'updated_at', 'preferred_dates', 'preferred_time_slots',
|
|
|
|
|
'selected_slots' # Make selected_slots read-only in this serializer
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
def get_can_join_meeting(self, obj):
|
|
|
|
|
@ -155,22 +159,16 @@ class AppointmentRequestSerializer(serializers.ModelSerializer):
|
|
|
|
|
if not is_authorized:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
# Staff can always see the URL (even when is_admin_join is False)
|
|
|
|
|
# But actual participants can only see it when is_admin_join is True
|
|
|
|
|
if current_user.is_staff:
|
|
|
|
|
# Staff can see the URL regardless of is_admin_join status
|
|
|
|
|
return obj.get_participant_join_url(
|
|
|
|
|
participant_user=participant_user,
|
|
|
|
|
include_password=True
|
|
|
|
|
)
|
|
|
|
|
else:
|
|
|
|
|
# Participants can only see the URL when is_admin_join is True
|
|
|
|
|
if obj.is_admin_join:
|
|
|
|
|
return obj.get_participant_join_url(
|
|
|
|
|
participant_user=participant_user,
|
|
|
|
|
include_password=True
|
|
|
|
|
)
|
|
|
|
|
return None
|
|
|
|
|
return obj.get_participant_join_url(
|
|
|
|
|
participant_user=participant_user,
|
|
|
|
|
include_password=True
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def get_meeting_analytics(self, obj):
|
|
|
|
|
return {
|
|
|
|
|
@ -226,56 +224,26 @@ class AppointmentRequestSerializer(serializers.ModelSerializer):
|
|
|
|
|
class AppointmentRequestCreateSerializer(serializers.ModelSerializer):
|
|
|
|
|
selected_slots = serializers.ListField(
|
|
|
|
|
child=serializers.DictField(),
|
|
|
|
|
write_only=True,
|
|
|
|
|
required=False,
|
|
|
|
|
required=True,
|
|
|
|
|
help_text="List of selected day-time combinations: [{'day': 0, 'time_slot': 'morning'}]"
|
|
|
|
|
)
|
|
|
|
|
available_slots_info = serializers.SerializerMethodField(read_only=True)
|
|
|
|
|
|
|
|
|
|
class Meta:
|
|
|
|
|
model = AppointmentRequest
|
|
|
|
|
fields = [
|
|
|
|
|
'first_name', 'last_name', 'email', 'phone', 'reason',
|
|
|
|
|
'preferred_dates', 'preferred_time_slots', 'selected_slots',
|
|
|
|
|
'available_slots_info'
|
|
|
|
|
'selected_slots'
|
|
|
|
|
]
|
|
|
|
|
extra_kwargs = {
|
|
|
|
|
'preferred_dates': {'required': False},
|
|
|
|
|
'preferred_time_slots': {'required': False}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
def get_available_slots_info(self, obj):
|
|
|
|
|
if not hasattr(obj, 'preferred_dates') or not obj.preferred_dates:
|
|
|
|
|
return {}
|
|
|
|
|
|
|
|
|
|
availability_info = {}
|
|
|
|
|
for date_str in obj.preferred_dates:
|
|
|
|
|
available_slots = check_date_availability(date_str)
|
|
|
|
|
availability_info[date_str] = {
|
|
|
|
|
'available_slots': available_slots,
|
|
|
|
|
'is_available': any(slot in available_slots for slot in obj.preferred_time_slots)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return availability_info
|
|
|
|
|
# Remove preferred_dates and preferred_time_slots from fields list
|
|
|
|
|
# They will be calculated automatically
|
|
|
|
|
|
|
|
|
|
def validate(self, data):
|
|
|
|
|
selected_slots = data.get('selected_slots')
|
|
|
|
|
preferred_dates = data.get('preferred_dates')
|
|
|
|
|
preferred_time_slots = data.get('preferred_time_slots')
|
|
|
|
|
|
|
|
|
|
if selected_slots:
|
|
|
|
|
return self._validate_selected_slots(data, selected_slots)
|
|
|
|
|
elif preferred_dates and preferred_time_slots:
|
|
|
|
|
return self._validate_old_format(data, preferred_dates, preferred_time_slots)
|
|
|
|
|
else:
|
|
|
|
|
raise serializers.ValidationError(
|
|
|
|
|
"Either provide 'selected_slots' or both 'preferred_dates' and 'preferred_time_slots'."
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def _validate_selected_slots(self, data, 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.")
|
|
|
|
|
@ -293,88 +261,75 @@ class AppointmentRequestCreateSerializer(serializers.ModelSerializer):
|
|
|
|
|
f"Slot {i+1}: '{time_slot}' on {day_name} is not available."
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
data['preferred_dates'] = self._convert_slots_to_dates(selected_slots)
|
|
|
|
|
data['preferred_time_slots'] = self._extract_time_slots(selected_slots)
|
|
|
|
|
# Add dates to the slots and store enhanced version
|
|
|
|
|
enhanced_slots = self._add_dates_to_slots(selected_slots)
|
|
|
|
|
data['selected_slots'] = enhanced_slots
|
|
|
|
|
|
|
|
|
|
del data['selected_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 _validate_old_format(self, data, preferred_dates, preferred_time_slots):
|
|
|
|
|
if not preferred_dates or len(preferred_dates) == 0:
|
|
|
|
|
raise serializers.ValidationError("At least one preferred date is required.")
|
|
|
|
|
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 = []
|
|
|
|
|
|
|
|
|
|
today = timezone.now().date()
|
|
|
|
|
for date_str in preferred_dates:
|
|
|
|
|
try:
|
|
|
|
|
date_obj = datetime.strptime(date_str, '%Y-%m-%d').date()
|
|
|
|
|
if date_obj < today:
|
|
|
|
|
raise serializers.ValidationError("Preferred dates cannot be in the past.")
|
|
|
|
|
|
|
|
|
|
available_slots = check_date_availability(date_str)
|
|
|
|
|
if not available_slots:
|
|
|
|
|
raise serializers.ValidationError(
|
|
|
|
|
f"No admin availability on {date_obj.strftime('%A, %B %d, %Y')}"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
except ValueError:
|
|
|
|
|
raise serializers.ValidationError(f"Invalid date format: {date_str}. Use YYYY-MM-DD.")
|
|
|
|
|
|
|
|
|
|
if not preferred_time_slots or len(preferred_time_slots) == 0:
|
|
|
|
|
raise serializers.ValidationError("At least one time slot is required.")
|
|
|
|
|
|
|
|
|
|
valid_slots = ['morning', 'afternoon', 'evening']
|
|
|
|
|
for slot in preferred_time_slots:
|
|
|
|
|
if slot not in valid_slots:
|
|
|
|
|
raise serializers.ValidationError(f"Invalid time slot: {slot}. Must be one of {valid_slots}.")
|
|
|
|
|
|
|
|
|
|
has_available_slot = False
|
|
|
|
|
for date_str in preferred_dates:
|
|
|
|
|
available_slots = check_date_availability(date_str)
|
|
|
|
|
if any(slot in available_slots for slot in preferred_time_slots):
|
|
|
|
|
has_available_slot = True
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
if not has_available_slot:
|
|
|
|
|
raise serializers.ValidationError(
|
|
|
|
|
"None of your preferred date and time combinations match the admin's availability. "
|
|
|
|
|
"Please check the admin's schedule and adjust your preferences."
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return data
|
|
|
|
|
|
|
|
|
|
def _convert_slots_to_dates(self, selected_slots):
|
|
|
|
|
today = timezone.now().date()
|
|
|
|
|
preferred_dates = []
|
|
|
|
|
# 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:
|
|
|
|
|
target_weekday = slot['day']
|
|
|
|
|
day = slot.get('day')
|
|
|
|
|
time_slot = slot.get('time_slot')
|
|
|
|
|
|
|
|
|
|
found_date = None
|
|
|
|
|
for days_ahead in range(1, 15):
|
|
|
|
|
check_date = today + timedelta(days=days_ahead)
|
|
|
|
|
if check_date.weekday() == target_weekday:
|
|
|
|
|
found_date = check_date
|
|
|
|
|
break
|
|
|
|
|
# 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:
|
|
|
|
|
date_str = found_date.strftime('%Y-%m-%d')
|
|
|
|
|
if date_str not in preferred_dates:
|
|
|
|
|
preferred_dates.append(date_str)
|
|
|
|
|
if found_date:
|
|
|
|
|
day_date_map[day] = found_date.strftime('%Y-%m-%d')
|
|
|
|
|
|
|
|
|
|
return preferred_dates
|
|
|
|
|
# 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]
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
def _extract_time_slots(self, selected_slots):
|
|
|
|
|
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 selected_slots:
|
|
|
|
|
if slot['time_slot'] not in time_slots:
|
|
|
|
|
time_slots.append(slot['time_slot'])
|
|
|
|
|
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)
|
|
|
|
|
@ -497,7 +452,7 @@ class AppointmentDetailSerializer(serializers.ModelSerializer):
|
|
|
|
|
'created_at', 'updated_at',
|
|
|
|
|
'meeting_info', 'meeting_analytics',
|
|
|
|
|
'can_join_as_moderator', 'can_join_as_participant',
|
|
|
|
|
'moderator_join_url', 'participant_join_url', 'is_admin_join',
|
|
|
|
|
'moderator_join_url', 'participant_join_url'
|
|
|
|
|
]
|
|
|
|
|
read_only_fields = ['id', 'created_at', 'updated_at']
|
|
|
|
|
|
|
|
|
|
@ -583,42 +538,33 @@ class AppointmentDetailSerializer(serializers.ModelSerializer):
|
|
|
|
|
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
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
# Staff can always see the URL (even when is_admin_join is False)
|
|
|
|
|
# But actual participants can only see it when is_admin_join is True
|
|
|
|
|
if current_user.is_staff:
|
|
|
|
|
# Staff can see the URL regardless of is_admin_join status
|
|
|
|
|
return obj.get_participant_join_url(
|
|
|
|
|
participant_user=participant_user,
|
|
|
|
|
include_password=True
|
|
|
|
|
)
|
|
|
|
|
else:
|
|
|
|
|
# Participants can only see the URL when is_admin_join is True
|
|
|
|
|
if obj.is_admin_join:
|
|
|
|
|
return obj.get_participant_join_url(
|
|
|
|
|
participant_user=participant_user,
|
|
|
|
|
include_password=True
|
|
|
|
|
)
|
|
|
|
|
return None
|
|
|
|
|
return obj.get_participant_join_url(
|
|
|
|
|
participant_user=participant_user,
|
|
|
|
|
include_password=True
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
class JitsiMeetingSerializer(serializers.ModelSerializer):
|
|
|
|
|
moderator_join_url = serializers.SerializerMethodField()
|
|
|
|
|
@ -637,12 +583,37 @@ class JitsiMeetingSerializer(serializers.ModelSerializer):
|
|
|
|
|
def get_moderator_join_url(self, obj):
|
|
|
|
|
if not obj.has_jitsi_meeting:
|
|
|
|
|
return None
|
|
|
|
|
return obj.get_moderator_join_url()
|
|
|
|
|
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
|
|
|
|
|
return obj.get_participant_join_url()
|
|
|
|
|
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:
|
|
|
|
|
|