feat: add selected_slots field to AppointmentRequest model and update serializers #53

Merged
Saani merged 1 commits from feature/meetings into main 2025-12-03 15:46:26 +00:00
5 changed files with 144 additions and 144 deletions

View File

@ -0,0 +1,17 @@
# Generated by Django 5.2.8 on 2025-12-03 14:33
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('meetings', '0009_appointmentrequest_is_admin_join'),
]
operations = [
migrations.RemoveField(
model_name='appointmentrequest',
name='is_admin_join',
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 5.2.8 on 2025-12-03 15:28
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('meetings', '0010_remove_appointmentrequest_is_admin_join'),
]
operations = [
migrations.AddField(
model_name='appointmentrequest',
name='selected_slots',
field=models.JSONField(default=list, help_text='Original selected slots with day and time slot pairs'),
),
]

View File

@ -239,7 +239,10 @@ class AppointmentRequest(models.Model):
default=dict, default=dict,
help_text="Additional meeting data (participants, duration, etc)" help_text="Additional meeting data (participants, duration, etc)"
) )
selected_slots = models.JSONField(
default=list,
help_text="Original selected slots with day and time slot pairs"
)
meeting_started_at = models.DateTimeField(null=True, blank=True) meeting_started_at = models.DateTimeField(null=True, blank=True)
meeting_ended_at = models.DateTimeField(null=True, blank=True) meeting_ended_at = models.DateTimeField(null=True, blank=True)
meeting_duration_actual = models.PositiveIntegerField( meeting_duration_actual = models.PositiveIntegerField(
@ -247,11 +250,6 @@ class AppointmentRequest(models.Model):
help_text="Actual meeting duration in minutes" help_text="Actual meeting duration in minutes"
) )
is_admin_join = models.BooleanField(
default=False,
help_text="When True, participants are allowed to join the meeting. Admin must join first and set this to True."
)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
@ -634,9 +632,6 @@ class AppointmentRequest(models.Model):
if user_type == 'moderator': if user_type == 'moderator':
return meeting_start - timedelta(minutes=15) <= now <= meeting_end + timedelta(minutes=15) return meeting_start - timedelta(minutes=15) <= now <= meeting_end + timedelta(minutes=15)
else: else:
# Participants can only join when is_admin_join is True
if not self.is_admin_join:
return False
return meeting_start - timedelta(minutes=5) <= now <= meeting_end return meeting_start - timedelta(minutes=5) <= now <= meeting_end
def schedule_appointment(self, datetime_obj, moderator_user, participant_user=None, duration=60, create_meeting=True, commit=True): def schedule_appointment(self, datetime_obj, moderator_user, participant_user=None, duration=60, create_meeting=True, commit=True):

View File

@ -80,11 +80,14 @@ class AppointmentRequestSerializer(serializers.ModelSerializer):
participant_join_url = serializers.SerializerMethodField() participant_join_url = serializers.SerializerMethodField()
meeting_analytics = serializers.SerializerMethodField() meeting_analytics = serializers.SerializerMethodField()
# Add selected_slots field
selected_slots = serializers.JSONField()
class Meta: class Meta:
model = AppointmentRequest model = AppointmentRequest
fields = [ fields = [
'id', 'first_name', 'last_name', 'email', 'phone', 'reason', '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', 'scheduled_datetime', 'scheduled_duration', 'rejection_reason',
'jitsi_meet_url', 'jitsi_room_id', 'created_at', 'updated_at', 'jitsi_meet_url', 'jitsi_room_id', 'created_at', 'updated_at',
'full_name', 'formatted_created_at', 'formatted_scheduled_datetime', 'full_name', 'formatted_created_at', 'formatted_scheduled_datetime',
@ -92,14 +95,15 @@ class AppointmentRequestSerializer(serializers.ModelSerializer):
'has_jitsi_meeting', 'can_join_meeting', 'can_join_as_moderator', 'can_join_as_participant', 'has_jitsi_meeting', 'can_join_meeting', 'can_join_as_moderator', 'can_join_as_participant',
'meeting_status', 'meeting_duration_display', 'matching_availability', 'meeting_status', 'meeting_duration_display', 'matching_availability',
'are_preferences_available', 'moderator_join_url', 'participant_join_url', 'are_preferences_available', 'moderator_join_url', 'participant_join_url',
'meeting_analytics', 'is_admin_join' 'meeting_analytics'
] ]
read_only_fields = [ read_only_fields = [
'id', 'status', 'scheduled_datetime', 'scheduled_duration', 'id', 'status', 'scheduled_datetime', 'scheduled_duration',
'rejection_reason', 'jitsi_meet_url', 'jitsi_room_id', '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): def get_can_join_meeting(self, obj):
return obj.can_join_meeting('participant') return obj.can_join_meeting('participant')
@ -155,22 +159,16 @@ class AppointmentRequestSerializer(serializers.ModelSerializer):
if not is_authorized: if not is_authorized:
return None 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: if current_user.is_staff:
# Staff can see the URL regardless of is_admin_join status
return obj.get_participant_join_url( return obj.get_participant_join_url(
participant_user=participant_user, participant_user=participant_user,
include_password=True include_password=True
) )
else: else:
# Participants can only see the URL when is_admin_join is True return obj.get_participant_join_url(
if obj.is_admin_join: participant_user=participant_user,
return obj.get_participant_join_url( include_password=True
participant_user=participant_user, )
include_password=True
)
return None
def get_meeting_analytics(self, obj): def get_meeting_analytics(self, obj):
return { return {
@ -226,56 +224,26 @@ class AppointmentRequestSerializer(serializers.ModelSerializer):
class AppointmentRequestCreateSerializer(serializers.ModelSerializer): class AppointmentRequestCreateSerializer(serializers.ModelSerializer):
selected_slots = serializers.ListField( selected_slots = serializers.ListField(
child=serializers.DictField(), child=serializers.DictField(),
write_only=True, required=True,
required=False,
help_text="List of selected day-time combinations: [{'day': 0, 'time_slot': 'morning'}]" help_text="List of selected day-time combinations: [{'day': 0, 'time_slot': 'morning'}]"
) )
available_slots_info = serializers.SerializerMethodField(read_only=True)
class Meta: class Meta:
model = AppointmentRequest model = AppointmentRequest
fields = [ fields = [
'first_name', 'last_name', 'email', 'phone', 'reason', 'first_name', 'last_name', 'email', 'phone', 'reason',
'preferred_dates', 'preferred_time_slots', 'selected_slots', 'selected_slots'
'available_slots_info'
] ]
extra_kwargs = { # Remove preferred_dates and preferred_time_slots from fields list
'preferred_dates': {'required': False}, # They will be calculated automatically
'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
def validate(self, data): def validate(self, data):
selected_slots = data.get('selected_slots') 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: if not selected_slots:
raise serializers.ValidationError("At least one time slot must be selected.") raise serializers.ValidationError("At least one time slot must be selected.")
# Your existing validation logic
availability = get_admin_availability() availability = get_admin_availability()
if not availability: if not availability:
raise serializers.ValidationError("No admin availability set.") 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." f"Slot {i+1}: '{time_slot}' on {day_name} is not available."
) )
data['preferred_dates'] = self._convert_slots_to_dates(selected_slots) # Add dates to the slots and store enhanced version
data['preferred_time_slots'] = self._extract_time_slots(selected_slots) 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 return data
def _validate_old_format(self, data, preferred_dates, preferred_time_slots): def _add_dates_to_slots(self, selected_slots):
if not preferred_dates or len(preferred_dates) == 0: """Add actual dates to the slots based on day numbers"""
raise serializers.ValidationError("At least one preferred date is required.") from datetime import datetime
today = datetime.now().date()
enhanced_slots = []
today = timezone.now().date() # We need to find the next occurrence of each day
for date_str in preferred_dates: # Keep track of which dates we've assigned to which day
try: day_date_map = {}
date_obj = datetime.strptime(date_str, '%Y-%m-%d').date()
if date_obj < today: for slot in selected_slots:
raise serializers.ValidationError("Preferred dates cannot be in the past.") 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
available_slots = check_date_availability(date_str) if found_date:
if not available_slots: day_date_map[day] = found_date.strftime('%Y-%m-%d')
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 = []
for slot in selected_slots:
target_weekday = slot['day']
found_date = None # Add the slot with date
for days_ahead in range(1, 15): if day in day_date_map:
check_date = today + timedelta(days=days_ahead) enhanced_slots.append({
if check_date.weekday() == target_weekday: 'day': day,
found_date = check_date 'time_slot': time_slot,
break 'date': day_date_map[day]
})
if found_date:
date_str = found_date.strftime('%Y-%m-%d')
if date_str not in preferred_dates:
preferred_dates.append(date_str)
return preferred_dates return enhanced_slots
def _extract_time_slots(self, selected_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 = [] time_slots = []
for slot in selected_slots: for slot in enhanced_slots:
if slot['time_slot'] not in time_slots: time_slot = slot.get('time_slot')
time_slots.append(slot['time_slot']) if time_slot and time_slot not in time_slots:
time_slots.append(time_slot)
return time_slots return time_slots
def create(self, validated_data): 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) return super().create(validated_data)
class AppointmentScheduleSerializer(serializers.Serializer): class AppointmentScheduleSerializer(serializers.Serializer):
scheduled_datetime = serializers.DateTimeField() scheduled_datetime = serializers.DateTimeField()
scheduled_duration = serializers.IntegerField(default=60, min_value=30, max_value=240) scheduled_duration = serializers.IntegerField(default=60, min_value=30, max_value=240)
@ -497,7 +452,7 @@ class AppointmentDetailSerializer(serializers.ModelSerializer):
'created_at', 'updated_at', 'created_at', 'updated_at',
'meeting_info', 'meeting_analytics', 'meeting_info', 'meeting_analytics',
'can_join_as_moderator', 'can_join_as_participant', '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'] 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: if not obj.has_jitsi_meeting or not obj.email:
return None return None
# Try to get the participant user from the appointment's email
participant_user = None participant_user = None
try: try:
from users.models import CustomUser from users.models import CustomUser
participant_user = CustomUser.objects.get(email=obj.email) participant_user = CustomUser.objects.get(email=obj.email)
except CustomUser.DoesNotExist: except CustomUser.DoesNotExist:
# Participant user doesn't exist (not registered)
return None return None
# Check if current user is authorized to view this URL
current_user = request.user current_user = request.user
is_authorized = ( is_authorized = (
current_user.is_staff or # Staff can see participant URLs for all appointments current_user.is_staff or
current_user.email == obj.email or # Participant can see their own URL current_user.email == obj.email or
(hasattr(obj, 'invited_participants') and current_user.email in obj.invited_participants) (hasattr(obj, 'invited_participants') and current_user.email in obj.invited_participants)
) )
if not is_authorized: if not is_authorized:
return None 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: if current_user.is_staff:
# Staff can see the URL regardless of is_admin_join status
return obj.get_participant_join_url( return obj.get_participant_join_url(
participant_user=participant_user, participant_user=participant_user,
include_password=True include_password=True
) )
else: else:
# Participants can only see the URL when is_admin_join is True return obj.get_participant_join_url(
if obj.is_admin_join: participant_user=participant_user,
return obj.get_participant_join_url( include_password=True
participant_user=participant_user, )
include_password=True
)
return None
class JitsiMeetingSerializer(serializers.ModelSerializer): class JitsiMeetingSerializer(serializers.ModelSerializer):
moderator_join_url = serializers.SerializerMethodField() moderator_join_url = serializers.SerializerMethodField()
@ -637,12 +583,37 @@ class JitsiMeetingSerializer(serializers.ModelSerializer):
def get_moderator_join_url(self, obj): def get_moderator_join_url(self, obj):
if not obj.has_jitsi_meeting: if not obj.has_jitsi_meeting:
return None 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): def get_participant_join_url(self, obj):
if not obj.has_jitsi_meeting: if not obj.has_jitsi_meeting:
return None 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): def get_meeting_config(self, obj):
if not obj.jitsi_meeting_config: if not obj.jitsi_meeting_config:

View File

@ -416,7 +416,6 @@ class JoinMeetingView(generics.GenericAPIView):
def get(self, request, pk): def get(self, request, pk):
appointment = self.get_object() appointment = self.get_object()
# Check permissions
if not self._has_join_permission(request.user, appointment): if not self._has_join_permission(request.user, appointment):
return Response( return Response(
{'error': 'You do not have permission to join this meeting'}, {'error': 'You do not have permission to join this meeting'},