diff --git a/booking_system/views.py b/booking_system/views.py index c75db95..9c3718d 100644 --- a/booking_system/views.py +++ b/booking_system/views.py @@ -148,235 +148,349 @@ def api_root(request, format=None): } }, "appointments": { - "description": "Appointment request and management system with Jitsi video meetings", - "base_path": "/api/meetings/", - "endpoints": { - "admin_availability": { - "description": "Get or update admin weekly availability (Admin only)", - "url": request.build_absolute_uri("/api/meetings/admin/availability/"), - "methods": ["GET", "PUT", "PATCH"], - "authentication": "Required (Staff users only)", - "response_fields": { - "available_days": "List of weekday numbers (0-6) when appointments are accepted", - "available_days_display": "Human-readable day names" + "description": "Appointment request and management system with Jitsi video meetings and flexible availability", + "base_path": "/api/meetings/", + "endpoints": { + "admin_availability": { + "description": "Get or update admin weekly availability with day-time combinations (Admin only)", + "url": request.build_absolute_uri("/api/meetings/admin/availability/"), + "methods": ["GET", "PUT", "PATCH"], + "authentication": "Required (Staff users only)", + "response_fields": { + "availability_schedule": "Dictionary with days as keys and time slots as values", + "availability_schedule_display": "Human-readable availability schedule", + "all_available_slots": "All available day-time combinations" + }, + "example_request": { + "availability_schedule": { + "0": ["morning", "evening"], + "1": ["morning", "afternoon"], + "3": ["afternoon", "evening"] + } + } + }, + "availability_config": { + "description": "Get availability configuration for frontend (Public)", + "url": request.build_absolute_uri("/api/meetings/availability/config/"), + "methods": ["GET"], + "authentication": "None required", + "response": "Default availability configuration with all days and time slots" + }, + "check_date_availability": { + "description": "Check available time slots for a specific date (Public)", + "url": request.build_absolute_uri("/api/meetings/availability/check/"), + "methods": ["POST"], + "authentication": "None required", + "required_fields": ["date (YYYY-MM-DD)"], + "example_request": { + "date": "2024-01-15" + }, + "response_fields": { + "date": "The checked date", + "day_name": "Day of the week", + "available_slots": "List of available time slots", + "available_slots_display": "Human-readable time slots", + "is_available": "Boolean indicating if any slots are available" + } + }, + "weekly_availability": { + "description": "Get complete weekly availability overview (Public)", + "url": request.build_absolute_uri("/api/meetings/availability/weekly/"), + "methods": ["GET"], + "authentication": "None required", + "response": "Array of days with their available time slots for the entire week" + }, + "availability_overview": { + "description": "Get public availability overview and next available dates (Public)", + "url": request.build_absolute_uri("/api/meetings/availability/overview/"), + "methods": ["GET"], + "authentication": "None required", + "response_fields": { + "available": "Boolean indicating if admin has any availability", + "total_available_slots": "Total number of available day-time slots", + "available_days": "List of days with availability", + "next_available_dates": "Next 7 days with availability information" + } + }, + + "available_dates": { + "description": "Get available appointment dates with time slots for the next 30 days (Public)", + "url": request.build_absolute_uri("/api/meetings/appointments/available-dates/"), + "methods": ["GET"], + "authentication": "None required", + "response": "List of available dates with their available time slots" + }, + "create_appointment": { + "description": "Create a new appointment request with availability validation (Public)", + "url": request.build_absolute_uri("/api/meetings/appointments/create/"), + "methods": ["POST"], + "authentication": "Required (User only)", + "required_fields": [ + "first_name", "last_name", "email", + "selected_slots" + ], + "optional_fields": ["phone", "reason"], + "validation": [ + "Selected slots must match admin availability", + "At least one time slot must be selected" + ], + "example_request": { + "first_name": "Shani", + "last_name": "Iddi", + "email": "saanii929@gmail.com", + "phone": "+233552732025", + "reason": "Therapy session", + "selected_slots": [ + {"day": 1, "time_slot": "morning"}, + {"day": 1, "time_slot": "afternoon"}, + {"day": 3, "time_slot": "afternoon"}, + {"day": 3, "time_slot": "evening"}, + {"day": 4, "time_slot": "evening"} + ] + }, + "response_includes": { + "appointment_id": "UUID of the created appointment", + "message": "Success message" + } + }, + "list_appointments": { + "description": "List appointment requests (Admin sees all, users see their own)", + "url": request.build_absolute_uri("/api/meetings/appointments/"), + "methods": ["GET"], + "authentication": "Required", + "response_fields": { + "jitsi_meet_url": "Jitsi meeting URL (only for scheduled appointments)", + "jitsi_room_id": "Jitsi room ID", + "has_jitsi_meeting": "Boolean indicating if meeting is created", + "can_join_meeting": "Boolean indicating if meeting can be joined now", + "meeting_status": "Current meeting status", + "matching_availability": "Date-time combinations that match admin availability", + "are_preferences_available": "Boolean indicating if preferences match availability" + } + }, + "appointment_detail": { + "description": "Get detailed information about a specific appointment", + "url": request.build_absolute_uri("/api/meetings/appointments//"), + "methods": ["GET"], + "authentication": "Required", + "url_parameter": "pk (UUID of the appointment)", + "response_includes": "Jitsi meeting information and availability matching data" + }, + "matching_availability": { + "description": "Get matching availability for a specific appointment request", + "url": request.build_absolute_uri("/api/meetings/appointments//matching-availability/"), + "methods": ["GET"], + "authentication": "Required", + "response_fields": { + "appointment_id": "UUID of the appointment", + "preferences_match_availability": "Boolean indicating if preferences match", + "matching_slots": "List of date-time combinations that match", + "total_matching_slots": "Number of matching combinations" + } + }, + "user_appointments": { + "description": "Get appointments for the authenticated user", + "url": request.build_absolute_uri("/api/meetings/user/appointments/"), + "methods": ["GET"], + "authentication": "Required", + "response": "List of user's appointment requests with enhanced availability data" + }, + "schedule_appointment": { + "description": "Schedule an appointment and automatically create Jitsi meeting (Admin only)", + "url": request.build_absolute_uri("/api/meetings/appointments//schedule/"), + "methods": ["POST"], + "authentication": "Required (Staff users only)", + "required_fields": ["scheduled_datetime"], + "optional_fields": ["scheduled_duration", "date_str", "time_slot"], + "prerequisites": "Appointment must be in 'pending_review' status", + "scheduling_options": { + "direct_datetime": { + "example": {"scheduled_datetime": "2024-01-15T10:00:00Z", "scheduled_duration": 60} + }, + "date_and_slot": { + "example": {"date_str": "2024-01-15", "time_slot": "morning", "scheduled_duration": 60} + } + }, + "validation": "Validates against admin availability when using date_str + time_slot", + "side_effects": [ + "Updates status to 'scheduled'", + "Automatically generates Jitsi meeting room", + "Creates unique Jitsi room ID and URL", + "Sends confirmation email to user with meeting link", + "Clears rejection reason if any" + ], + "response_includes": { + "jitsi_meet_url": "Generated Jitsi meeting URL", + "jitsi_room_id": "Unique Jitsi room ID", + "has_jitsi_meeting": "true" + } + }, + "reject_appointment": { + "description": "Reject an appointment request (Admin only)", + "url": request.build_absolute_uri("/api/meetings/appointments//reject/"), + "methods": ["POST"], + "authentication": "Required (Staff users only)", + "optional_fields": ["rejection_reason"], + "prerequisites": "Appointment must be in 'pending_review' status", + "example_request": { + "rejection_reason": "No availability for preferred dates" + }, + "side_effects": [ + "Updates status to 'rejected'", + "Clears Jitsi meeting information", + "Sends rejection email to user", + "Clears scheduled datetime if any" + ] + }, + + "appointment_stats": { + "description": "Get appointment statistics and analytics with availability metrics (Admin only)", + "url": request.build_absolute_uri("/api/meetings/appointments/stats/"), + "methods": ["GET"], + "authentication": "Required (Staff users only)", + "response_fields": { + "total_requests": "Total number of appointment requests", + "pending_review": "Number of pending review requests", + "scheduled": "Number of scheduled appointments", + "rejected": "Number of rejected requests", + "completed": "Number of completed appointments", + "completion_rate": "Percentage of requests that were scheduled", + "availability_coverage": "Percentage of week covered by availability", + "available_days_count": "Number of days with availability set" + } + }, + "user_appointment_stats": { + "description": "Get appointment statistics for a specific user", + "url": request.build_absolute_uri("/api/meetings/user/appointments/stats/"), + "methods": ["POST"], + "authentication": "Required", + "required_fields": ["email"], + "response_fields": { + "total_requests": "Total number of appointment requests", + "pending_review": "Number of pending review requests", + "scheduled": "Number of scheduled appointments", + "rejected": "Number of rejected requests", + "completed": "Number of completed appointments", + "completion_rate": "Percentage of requests that were scheduled", + "email": "User email address" + } + } }, - "example_request": { - "available_days": [0, 1, 2, 3, 4] - } - }, - "available_dates": { - "description": "Get available appointment dates for the next 30 days (Public)", - "url": request.build_absolute_uri("/api/meetings/appointments/available-dates/"), - "methods": ["GET"], - "authentication": "None required", - "response": "List of available dates in YYYY-MM-DD format" - }, - "create_appointment": { - "description": "Create a new appointment request (Public)", - "url": request.build_absolute_uri("/api/meetings/appointments/create/"), - "methods": ["POST"], - "authentication": "Required (User only)", - "required_fields": [ - "first_name", "last_name", "email", - "preferred_dates", "preferred_time_slots" - ], - "optional_fields": ["phone", "reason"], - "example_request": { - "first_name": "John", - "last_name": "Doe", - "email": "john@example.com", - "phone": "+1234567890", - "reason": "Initial consultation for anxiety", - "preferred_dates": ["2024-01-15", "2024-01-16"], - "preferred_time_slots": ["morning", "afternoon"] + + "availability_system": { + "description": "Flexible day-time availability management", + "features": [ + "Different time slots for each day of the week", + "Real-time availability validation", + "Matching preference detection", + "Weekly availability overview" + ], + "time_slots": { + "morning": "Morning (9AM - 12PM)", + "afternoon": "Afternoon (1PM - 5PM)", + "evening": "Evening (6PM - 9PM)" + }, + "days_of_week": { + "0": "Monday", + "1": "Tuesday", + "2": "Wednesday", + "3": "Thursday", + "4": "Friday", + "5": "Saturday", + "6": "Sunday" + } }, - "validation": "Preferred dates must be within admin available days" - }, - "list_appointments": { - "description": "List appointment requests (Admin sees all, users see their own)", - "url": request.build_absolute_uri("/api/meetings/appointments/"), - "methods": ["GET"], - "authentication": "Required", - "query_parameters": { - "email": "For non-authenticated user lookup (simplified approach)" - }, - "response_fields": { - "jitsi_meet_url": "Jitsi meeting URL (only for scheduled appointments)", - "jitsi_room_id": "Jitsi room ID", - "has_jitsi_meeting": "Boolean indicating if meeting is created", - "can_join_meeting": "Boolean indicating if meeting can be joined now", - "meeting_status": "Current meeting status" + + "jitsi_integration": { + "description": "Automatic Jitsi video meeting integration", + "features": [ + "Automatic meeting room generation when appointment is scheduled", + "Unique room IDs for each therapy session", + "No setup required for clients - just click and join", + "Meeting availability based on scheduled time", + "Secure, encrypted video sessions" + ], + "meeting_lifecycle": { + "pending": "No Jitsi meeting created", + "scheduled": "Jitsi meeting automatically generated with unique URL", + "active": "Meeting can be joined 10 minutes before scheduled time", + "completed": "Meeting ends 15 minutes after scheduled duration" + }, + "join_conditions": [ + "Appointment must be in 'scheduled' status", + "Current time must be within 10 minutes before to 15 minutes after scheduled end", + "Both client and therapist can join using the same URL" + ] } - }, - "appointment_detail": { - "description": "Get detailed information about a specific appointment", - "url": request.build_absolute_uri("/api/meetings/appointments//"), - "methods": ["GET"], - "authentication": "Required", - "url_parameter": "pk (UUID of the appointment)", - "response_includes": "Jitsi meeting information for scheduled appointments" - }, - "user_appointments": { - "description": "Get appointments for the authenticated user", - "url": request.build_absolute_uri("/api/meetings/user/appointments/"), - "methods": ["GET"], - "authentication": "Required", - "response": "List of user's appointment requests with Jitsi meeting details" - }, - "schedule_appointment": { - "description": "Schedule an appointment and automatically create Jitsi meeting (Admin only)", - "url": request.build_absolute_uri("/api/meetings/appointments//schedule/"), - "methods": ["POST"], - "authentication": "Required (Staff users only)", - "required_fields": ["scheduled_datetime"], - "optional_fields": ["scheduled_duration"], - "prerequisites": "Appointment must be in 'pending_review' status", - "example_request": { - "scheduled_datetime": "2024-01-15T10:00:00Z", - "scheduled_duration": 60 - }, - "side_effects": [ - "Updates status to 'scheduled'", - "Automatically generates Jitsi meeting room", - "Creates unique Jitsi room ID and URL", - "Sends confirmation email to user with meeting link", - "Clears rejection reason if any" - ], - "response_includes": { - "jitsi_meet_url": "Generated Jitsi meeting URL", - "jitsi_room_id": "Unique Jitsi room ID", - "has_jitsi_meeting": "true" - } - }, - "reject_appointment": { - "description": "Reject an appointment request (Admin only)", - "url": request.build_absolute_uri("/api/meetings/appointments//reject/"), - "methods": ["POST"], - "authentication": "Required (Staff users only)", - "optional_fields": ["rejection_reason"], - "prerequisites": "Appointment must be in 'pending_review' status", - "example_request": { - "rejection_reason": "No availability for preferred dates" - }, - "side_effects": [ - "Updates status to 'rejected'", - "Clears Jitsi meeting information", - "Sends rejection email to user", - "Clears scheduled datetime if any" - ] - }, - "jitsi_meeting_info": { - "description": "Get Jitsi meeting information for a scheduled appointment", - "url": request.build_absolute_uri("/api/meetings/appointments//jitsi-meeting/"), - "methods": ["GET"], - "authentication": "Required", - "prerequisites": "Appointment must be in 'scheduled' status", - "response_fields": { - "meeting_url": "Jitsi meeting URL", - "room_id": "Jitsi room ID", - "scheduled_time": "Formatted scheduled datetime", - "duration": "Meeting duration display", - "can_join": "Boolean indicating if meeting can be joined now", - "meeting_status": "Current meeting status", - "join_instructions": "Instructions for joining the meeting" - } - }, - "appointment_stats": { - "description": "Get appointment statistics and analytics (Admin only)", - "url": request.build_absolute_uri("/api/meetings/appointments/stats/"), - "methods": ["GET"], - "authentication": "Required (Staff users only)", - "response_fields": { - "total_requests": "Total number of appointment requests", - "pending_review": "Number of pending review requests", - "scheduled": "Number of scheduled appointments", - "rejected": "Number of rejected requests", - "completion_rate": "Percentage of requests that were scheduled" - } - }, - "user_appointment_stats": { - "description": "Get appointment statistics and analytics for the authenticated user", - "url": request.build_absolute_uri("/api/meetings/user/appointments/stats/"), - "methods": ["POST"], - "authentication": "Required", - "required_fields": ["email"], - "response_fields": { - "total_requests": "Total number of appointment requests", - "pending_review": "Number of pending review requests", - "scheduled": "Number of scheduled appointments", - "rejected": "Number of rejected requests", - "completed": "Number of completed appointments", - "completion_rate": "Percentage of requests that were scheduled" - } - }, - - }, - "jitsi_integration": { - "description": "Automatic Jitsi video meeting integration", - "features": [ - "Automatic meeting room generation when appointment is scheduled", - "Unique room IDs for each therapy session", - "No setup required for clients - just click and join", - "Meeting availability based on scheduled time", - "Secure, encrypted video sessions" - ], - "meeting_lifecycle": { - "pending": "No Jitsi meeting created", - "scheduled": "Jitsi meeting automatically generated with unique URL", - "active": "Meeting can be joined 10 minutes before scheduled time", - "completed": "Meeting ends 15 minutes after scheduled duration" - }, - "join_conditions": [ - "Appointment must be in 'scheduled' status", - "Current time must be within 10 minutes before to 15 minutes after scheduled end", - "Both client and therapist can join using the same URL" - ] - } -} + } } return Response({ - 'message': 'Therapy Appointment API', - 'version': '1.0.0', + 'message': 'Therapy Appointment API with Enhanced Availability System', + 'version': '2.0.0', 'base_url': base_url, + 'new_features': [ + 'Flexible day-time availability management', + 'Real-time availability validation', + 'Matching preference detection', + 'Enhanced scheduling options', + 'Availability statistics and coverage metrics' + ], 'project_structure': { 'admin': '/admin/ - Django admin interface', 'authentication': '/api/auth/ - User authentication and management', - 'appointments': '/api/meetings/ - Appointment booking system' + 'appointments': '/api/meetings/ - Enhanced appointment booking system' }, 'endpoints': endpoints, 'appointment_workflows': { 'client_booking_flow': [ - '1. GET /api/meetings/appointments/available-dates/ - Check available dates', - '2. POST /api/meetings/appointments/create/ - Submit appointment request', - '3. GET /api/meetings/user/appointments/ - Track request status', - '4. Receive email notification when scheduled/rejected' + '1. GET /api/meetings/availability/weekly/ - Check weekly availability', + '2. POST /api/meetings/availability/check/ - Check specific date availability', + '3. GET /api/meetings/appointments/available-dates/ - See next available dates', + '4. POST /api/meetings/appointments/create/ - Submit appointment request', + '5. GET /api/meetings/user/appointments/ - Track request status', + '6. GET /api/meetings/appointments/{id}/matching-availability/ - See matching options', + '7. Receive email notification when scheduled/rejected' ], 'admin_management_flow': [ - '1. PUT /api/meetings/admin/availability/ - Set weekly availability', + '1. PUT /api/meetings/admin/availability/ - Set flexible day-time availability', '2. GET /api/meetings/appointments/ - Review pending requests', - '3. POST /api/meetings/appointments/{id}/schedule/ - Schedule appointment OR', - '4. POST /api/meetings/appointments/{id}/reject/ - Reject with reason', - '5. GET /api/meetings/appointments/stats/ - Monitor performance' + '3. GET /api/meetings/appointments/stats/ - Check availability coverage', + '4. POST /api/meetings/appointments/{id}/schedule/ - Schedule with date+slot OR direct datetime', + '5. POST /api/meetings/appointments/{id}/reject/ - Reject with reason' ], 'status_lifecycle': [ 'pending_review → scheduled (with datetime)', - 'pending_review → rejected (with optional reason)' + 'pending_review → rejected (with optional reason)', + 'scheduled → completed (after meeting)', + 'scheduled → cancelled (if needed)' ] }, - 'authentication_flows': { - 'registration_flow': [ - '1. POST /api/auth/register/ - Register user and send OTP', - '2. POST /api/auth/verify-otp/ - Verify email with OTP', - '3. POST /api/auth/login/ - Login with credentials' - ], - 'password_reset_flow': [ - '1. POST /api/auth/forgot-password/ - Request password reset OTP', - '2. POST /api/auth/verify-password-reset-otp/ - Verify OTP', - '3. POST /api/auth/reset-password/ - Set new password' - ] + 'availability_examples': { + 'monday_evening_only': { + "availability_schedule": { + "0": ["evening"] + } + }, + 'weekday_mornings_afternoons': { + "availability_schedule": { + "0": ["morning", "afternoon"], + "1": ["morning", "afternoon"], + "2": ["morning", "afternoon"], + "3": ["morning", "afternoon"], + "4": ["morning", "afternoon"] + } + }, + 'flexible_schedule': { + "availability_schedule": { + "0": ["morning", "evening"], + "1": ["afternoon"], + "3": ["morning", "afternoon"], + "5": ["morning"] + } + } }, 'quick_start': { @@ -384,14 +498,16 @@ def api_root(request, format=None): '1. Register: POST /api/auth/register/', '2. Verify email: POST /api/auth/verify-otp/', '3. Login: POST /api/auth/login/', - '4. Check availability: GET /api/meetings/appointments/available-dates/', - '5. Book appointment: POST /api/meetings/appointments/create/' + '4. Check weekly availability: GET /api/meetings/availability/weekly/', + '5. Check specific date: POST /api/meetings/availability/check/', + '6. Book appointment: POST /api/meetings/appointments/create/' ], 'for_admins': [ '1. Login to Django admin: /admin/', - '2. Set availability: PUT /api/meetings/admin/availability/', - '3. Manage appointments: GET /api/meetings/appointments/', - '4. Schedule/Reject: Use specific appointment endpoints' + '2. Set flexible availability: PUT /api/meetings/admin/availability/', + '3. Check availability coverage: GET /api/meetings/appointments/stats/', + '4. Manage appointments: GET /api/meetings/appointments/', + '5. Schedule/Reject: Use specific appointment endpoints' ] }, @@ -400,7 +516,9 @@ def api_root(request, format=None): 'status_choices': [ 'pending_review - Initial state, awaiting admin action', 'scheduled - Approved with specific date/time', - 'rejected - Not accepted, with optional reason' + 'rejected - Not accepted, with optional reason', + 'completed - Meeting has been completed', + 'cancelled - Appointment was cancelled' ], 'time_slot_choices': [ 'morning - 9AM to 12PM', @@ -415,7 +533,8 @@ def api_root(request, format=None): }, 'availability': { 'day_format': '0=Monday, 1=Tuesday, ..., 6=Sunday', - 'example': '[0, 1, 2, 3, 4] for Monday-Friday' + 'time_slot_format': 'morning, afternoon, evening', + 'schedule_format': 'Dictionary: {"0": ["morning", "evening"], "1": ["afternoon"]}' } }, @@ -423,7 +542,7 @@ def api_root(request, format=None): 'token_usage': 'Include JWT token in Authorization header: Bearer ', 'token_refresh': 'Use refresh token to get new access token when expired', 'permissions': { - 'public_endpoints': 'No authentication required', + 'public_endpoints': 'No authentication required (availability checks, overview)', 'user_endpoints': 'Valid JWT token required', 'admin_endpoints': 'Staff user with valid JWT token required' } diff --git a/meetings/admin.py b/meetings/admin.py index b9a029f..d4bff9e 100644 --- a/meetings/admin.py +++ b/meetings/admin.py @@ -1,33 +1,87 @@ +from django import forms from django.contrib import admin from .models import AdminWeeklyAvailability, AppointmentRequest +class AdminWeeklyAvailabilityForm(forms.ModelForm): + class Meta: + model = AdminWeeklyAvailability + fields = '__all__' + widgets = { + 'availability_schedule': forms.HiddenInput() + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + for day_num, day_name in AdminWeeklyAvailability.DAYS_OF_WEEK: + field_name = f'day_{day_num}' + self.fields[field_name] = forms.MultipleChoiceField( + choices=AdminWeeklyAvailability.TIME_SLOT_CHOICES, + required=False, + label=day_name, + widget=forms.CheckboxSelectMultiple + ) + + if self.instance.availability_schedule: + self.fields[field_name].initial = self.instance.availability_schedule.get(str(day_num), []) + + def save(self, commit=True): + instance = super().save(commit=False) + + availability_schedule = {} + for day_num, day_name in AdminWeeklyAvailability.DAYS_OF_WEEK: + field_name = f'day_{day_num}' + time_slots = self.cleaned_data.get(field_name, []) + if time_slots: + availability_schedule[str(day_num)] = time_slots + + instance.availability_schedule = availability_schedule + + if commit: + instance.save() + + return instance + @admin.register(AdminWeeklyAvailability) class AdminWeeklyAvailabilityAdmin(admin.ModelAdmin): - list_display = ['available_days_display', 'created_at'] + form = AdminWeeklyAvailabilityForm + list_display = ['__str__', 'created_at', 'updated_at'] - def available_days_display(self, obj): - days_map = dict(AdminWeeklyAvailability.DAYS_OF_WEEK) - return ', '.join([days_map[day] for day in obj.available_days]) - available_days_display.short_description = 'Available Days' + def has_add_permission(self, request): + if self.model.objects.count() >= 1: + return False + return super().has_add_permission(request) @admin.register(AppointmentRequest) class AppointmentRequestAdmin(admin.ModelAdmin): - list_display = ['full_name', 'email', 'status', 'created_at', 'scheduled_datetime'] - list_filter = ['status', 'created_at'] + list_display = ['full_name', 'email', 'status', 'formatted_created_at', 'formatted_scheduled_datetime'] + list_filter = ['status', 'created_at', 'scheduled_datetime'] search_fields = ['first_name', 'last_name', 'email'] - readonly_fields = ['created_at', 'updated_at'] - actions = ['mark_as_scheduled', 'mark_as_rejected'] + readonly_fields = ['id', 'created_at', 'updated_at', 'formatted_created_at', 'formatted_scheduled_datetime'] - def mark_as_scheduled(self, request, queryset): - for appointment in queryset: - if appointment.status == 'pending_review': - appointment.status = 'scheduled' - appointment.save() - mark_as_scheduled.short_description = "Mark selected as scheduled" + fieldsets = ( + ('Personal Information', { + 'fields': ('first_name', 'last_name', 'email', 'phone', 'reason') + }), + ('Appointment Preferences', { + 'fields': ('preferred_dates', 'preferred_time_slots') + }), + ('Scheduling', { + 'fields': ('status', 'scheduled_datetime', 'scheduled_duration', 'rejection_reason') + }), + ('Video Meeting', { + 'fields': ('jitsi_meet_url', 'jitsi_room_id') + }), + ('Metadata', { + 'fields': ('id', 'created_at', 'updated_at', 'formatted_created_at', 'formatted_scheduled_datetime'), + 'classes': ('collapse',) + }), + ) - def mark_as_rejected(self, request, queryset): - for appointment in queryset: - if appointment.status == 'pending_review': - appointment.status = 'rejected' - appointment.save() - mark_as_rejected.short_description = "Mark selected as rejected" \ No newline at end of file + def formatted_created_at(self, obj): + return obj.formatted_created_at + formatted_created_at.short_description = 'Created At' + + def formatted_scheduled_datetime(self, obj): + return obj.formatted_scheduled_datetime + formatted_scheduled_datetime.short_description = 'Scheduled Date Time' \ No newline at end of file diff --git a/meetings/models.py b/meetings/models.py index 0de0793..4b8c260 100644 --- a/meetings/models.py +++ b/meetings/models.py @@ -90,9 +90,15 @@ class AdminWeeklyAvailability(models.Model): (6, 'Sunday'), ] - available_days = models.JSONField( - default=list, - help_text="List of weekdays (0-6) when appointments are accepted" + TIME_SLOT_CHOICES = [ + ('morning', 'Morning (9AM - 12PM)'), + ('afternoon', 'Afternoon (1PM - 5PM)'), + ('evening', 'Evening (6PM - 9PM)'), + ] + + availability_schedule = models.JSONField( + default=dict, + help_text="Dictionary with days as keys and lists of time slots as values" ) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) @@ -101,9 +107,59 @@ class AdminWeeklyAvailability(models.Model): verbose_name = 'Admin Weekly Availability' verbose_name_plural = 'Admin Weekly Availability' + def set_availability(self, day, time_slots): + if not self.availability_schedule: + self.availability_schedule = {} + + if day not in [str(d[0]) for d in self.DAYS_OF_WEEK]: + raise ValueError(f"Invalid day: {day}") + + valid_slots = [slot[0] for slot in self.TIME_SLOT_CHOICES] + for slot in time_slots: + if slot not in valid_slots: + raise ValueError(f"Invalid time slot: {slot}") + + self.availability_schedule[str(day)] = time_slots + + def get_availability_for_day(self, day): + return self.availability_schedule.get(str(day), []) + + def is_available(self, day, time_slot): + return time_slot in self.get_availability_for_day(day) + + def get_all_available_slots(self): + available_slots = [] + for day_num, time_slots in self.availability_schedule.items(): + day_name = dict(self.DAYS_OF_WEEK).get(int(day_num)) + for time_slot in time_slots: + time_display = dict(self.TIME_SLOT_CHOICES).get(time_slot) + available_slots.append({ + 'day_num': int(day_num), + 'day_name': day_name, + 'time_slot': time_slot, + 'time_display': time_display + }) + return available_slots + + def clear_availability(self, day=None): + if day is None: + self.availability_schedule = {} + else: + day_str = str(day) + if day_str in self.availability_schedule: + del self.availability_schedule[day_str] + def __str__(self): - days = [self.DAYS_OF_WEEK[day][1] for day in self.available_days] - return f"Available: {', '.join(days)}" + if not self.availability_schedule: + return "No availability set" + + display_strings = [] + for day_num, time_slots in sorted(self.availability_schedule.items()): + day_name = dict(self.DAYS_OF_WEEK).get(int(day_num)) + slot_displays = [dict(self.TIME_SLOT_CHOICES).get(slot) for slot in time_slots] + display_strings.append(f"{day_name}: {', '.join(slot_displays)}") + + return " | ".join(display_strings) class AppointmentRequest(models.Model): STATUS_CHOICES = [ @@ -295,5 +351,101 @@ class AppointmentRequest(models.Model): else: return "Ended" + def get_available_time_slots_for_date(self, date_str): + try: + from datetime import datetime + date_obj = datetime.strptime(date_str, '%Y-%m-%d').date() + day_of_week = date_obj.weekday() + + availability = AdminWeeklyAvailability.objects.first() + if not availability: + return [] + + return availability.get_availability_for_day(day_of_week) + except Exception as e: + print(f"Error getting available slots: {e}") + return [] + + def are_preferences_available(self): + availability = AdminWeeklyAvailability.objects.first() + if not availability: + return False + + for date_str in self.preferred_dates: + try: + from datetime import datetime + date_obj = datetime.strptime(date_str, '%Y-%m-%d').date() + day_of_week = date_obj.weekday() + + available_slots = availability.get_availability_for_day(day_of_week) + if any(slot in available_slots for slot in self.preferred_time_slots): + return True + except Exception as e: + print(f"Error checking availability for {date_str}: {e}") + continue + + return False + + def get_matching_availability(self): + availability = AdminWeeklyAvailability.objects.first() + if not availability: + return [] + + matching_slots = [] + for date_str in self.preferred_dates: + try: + from datetime import datetime + date_obj = datetime.strptime(date_str, '%Y-%m-%d').date() + day_of_week = date_obj.weekday() + day_name = dict(AdminWeeklyAvailability.DAYS_OF_WEEK).get(day_of_week) + + available_slots = availability.get_availability_for_day(day_of_week) + matching_time_slots = [slot for slot in self.preferred_time_slots if slot in available_slots] + + if matching_time_slots: + matching_slots.append({ + 'date': date_str, + 'day_name': day_name, + 'available_slots': matching_time_slots, + 'date_obj': date_obj + }) + except Exception as e: + print(f"Error processing {date_str}: {e}") + continue + + return matching_slots + def __str__(self): - return f"{self.full_name} - {self.get_status_display()} - {self.created_at.strftime('%Y-%m-%d')}" \ No newline at end of file + return f"{self.full_name} - {self.get_status_display()} - {self.created_at.strftime('%Y-%m-%d')}" + + +def get_admin_availability(): + availability, created = AdminWeeklyAvailability.objects.get_or_create( + id=1 + ) + return availability + +def set_admin_availability(availability_dict): + availability = get_admin_availability() + + for day, time_slots in availability_dict.items(): + availability.set_availability(day, time_slots) + + availability.save() + return availability + +def get_available_slots_for_week(): + availability = get_admin_availability() + return availability.get_all_available_slots() + +def check_date_availability(date_str): + try: + from datetime import datetime + date_obj = datetime.strptime(date_str, '%Y-%m-%d').date() + day_of_week = date_obj.weekday() + + availability = get_admin_availability() + return availability.get_availability_for_day(day_of_week) + except Exception as e: + print(f"Error checking date availability: {e}") + return [] \ No newline at end of file diff --git a/meetings/serializers.py b/meetings/serializers.py index 4e6bc22..c31cf64 100644 --- a/meetings/serializers.py +++ b/meetings/serializers.py @@ -1,19 +1,64 @@ from rest_framework import serializers -from .models import AdminWeeklyAvailability, AppointmentRequest +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): - available_days_display = serializers.SerializerMethodField() + availability_schedule_display = serializers.SerializerMethodField() + all_available_slots = serializers.SerializerMethodField() class Meta: model = AdminWeeklyAvailability - fields = ['id', 'available_days', 'available_days_display', 'created_at', 'updated_at'] + fields = [ + 'id', 'availability_schedule', 'availability_schedule_display', + 'all_available_slots', 'created_at', 'updated_at' + ] - def get_available_days_display(self, obj): + def get_availability_schedule_display(self, obj): + if not obj.availability_schedule: + return "No availability set" + + display = {} days_map = dict(AdminWeeklyAvailability.DAYS_OF_WEEK) - return [days_map[day] for day in obj.available_days] + 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() @@ -26,6 +71,9 @@ class AppointmentRequestSerializer(serializers.ModelSerializer): jitsi_room_id = serializers.ReadOnlyField() can_join_meeting = serializers.ReadOnlyField() meeting_status = serializers.ReadOnlyField() + meeting_duration_display = serializers.ReadOnlyField() + matching_availability = serializers.SerializerMethodField() + are_preferences_available = serializers.SerializerMethodField() class Meta: model = AppointmentRequest @@ -36,56 +84,223 @@ class AppointmentRequestSerializer(serializers.ModelSerializer): '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', 'meeting_status' + 'has_jitsi_meeting', 'can_join_meeting', 'meeting_status', + 'meeting_duration_display', 'matching_availability', 'are_preferences_available' ] read_only_fields = [ 'id', 'status', 'scheduled_datetime', 'scheduled_duration', 'rejection_reason', 'jitsi_meet_url', 'jitsi_room_id', 'created_at', 'updated_at' ] + + def get_matching_availability(self, obj): + """Get matching availability for this appointment request""" + return obj.get_matching_availability() + + def get_are_preferences_available(self, obj): + """Check if preferences match admin availability""" + return obj.are_preferences_available() class AppointmentRequestCreateSerializer(serializers.ModelSerializer): + selected_slots = serializers.ListField( + child=serializers.DictField(), + write_only=True, + required=False, + 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' + 'preferred_dates', 'preferred_time_slots', 'selected_slots', + 'available_slots_info' ] + extra_kwargs = { + 'preferred_dates': {'required': False}, + 'preferred_time_slots': {'required': False} + } - def validate_preferred_dates(self, value): - if not value or len(value) == 0: + 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): + 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.") + + 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." + ) + + data['preferred_dates'] = self._convert_slots_to_dates(selected_slots) + data['preferred_time_slots'] = self._extract_time_slots(selected_slots) + + del data['selected_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.") today = timezone.now().date() - for date_str in value: + 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.") - return value - - def validate_preferred_time_slots(self, value): - if not value or len(value) == 0: + 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 value: + 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}.") - return value - + 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): + from datetime import timedelta + + today = timezone.now().date() + preferred_dates = [] + + for slot in selected_slots: + target_weekday = slot['day'] + + 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 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 + + def _extract_time_slots(self, selected_slots): + time_slots = [] + for slot in selected_slots: + if slot['time_slot'] not in time_slots: + time_slots.append(slot['time_slot']) + return time_slots + + def create(self, validated_data): + 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) - def validate_scheduled_datetime(self, value): - if value <= timezone.now(): + 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 value + + return data + + def _convert_to_datetime(self, date_obj, time_slot): + """Convert date and time slot to actual datetime""" + 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: @@ -95,4 +310,71 @@ class AppointmentScheduleSerializer(serializers.Serializer): return value class AppointmentRejectSerializer(serializers.Serializer): - rejection_reason = serializers.CharField(required=False, allow_blank=True) \ No newline at end of file + 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}) \ No newline at end of file diff --git a/meetings/urls.py b/meetings/urls.py index 7857b81..800a6f5 100644 --- a/meetings/urls.py +++ b/meetings/urls.py @@ -1,28 +1,39 @@ from django.urls import path from .views import ( AdminAvailabilityView, + AdminAvailabilityConfigView, AppointmentRequestListView, AppointmentRequestCreateView, AppointmentRequestDetailView, ScheduleAppointmentView, RejectAppointmentView, AvailableDatesView, + CheckDateAvailabilityView, + WeeklyAvailabilityView, UserAppointmentsView, AppointmentStatsView, - UserAppointmentStatsView + UserAppointmentStatsView, + MatchingAvailabilityView, + availability_overview ) urlpatterns = [ path('admin/availability/', AdminAvailabilityView.as_view(), name='admin-availability'), + path('availability/config/', AdminAvailabilityConfigView.as_view(), name='availability-config'), + path('availability/check/', CheckDateAvailabilityView.as_view(), name='check-availability'), + path('availability/weekly/', WeeklyAvailabilityView.as_view(), name='weekly-availability'), + path('availability/overview/', availability_overview, name='availability-overview'), path('appointments/', AppointmentRequestListView.as_view(), name='appointment-list'), path('appointments/create/', AppointmentRequestCreateView.as_view(), name='appointment-create'), path('appointments//', AppointmentRequestDetailView.as_view(), name='appointment-detail'), + path('appointments//matching-availability/', MatchingAvailabilityView.as_view(), name='matching-availability'), path('appointments//schedule/', ScheduleAppointmentView.as_view(), name='appointment-schedule'), path('appointments//reject/', RejectAppointmentView.as_view(), name='appointment-reject'), path('appointments/available-dates/', AvailableDatesView.as_view(), name='available-dates'), + path('user/appointments/', UserAppointmentsView.as_view(), name='user-appointments'), path('appointments/stats/', AppointmentStatsView.as_view(), name='appointment-stats'), diff --git a/meetings/views.py b/meetings/views.py index cfd2c75..bb866f0 100644 --- a/meetings/views.py +++ b/meetings/views.py @@ -1,16 +1,21 @@ 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.permissions import IsAuthenticated, AllowAny, IsAdminUser from django.utils import timezone from datetime import datetime, timedelta -from .models import AdminWeeklyAvailability, AppointmentRequest +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 + AppointmentRejectSerializer, + AvailabilityCheckSerializer, + AvailabilityResponseSerializer, + WeeklyAvailabilitySerializer, + AdminAvailabilityConfigSerializer ) from .email_service import EmailService from users.models import CustomUser @@ -19,13 +24,30 @@ from django.db.models import Count, Q class AdminAvailabilityView(generics.RetrieveUpdateAPIView): permission_classes = [IsAuthenticated, IsAdminUser] - serializer_class = AdminWeeklyAvailabilitySerializer + + def get_serializer_class(self): + if self.request.method == 'GET': + return AdminWeeklyAvailabilitySerializer + return AdminWeeklyAvailabilityUpdateSerializer def get_object(self): - obj, created = AdminWeeklyAvailability.objects.get_or_create( - defaults={'available_days': []} - ) - return obj + 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 @@ -39,25 +61,20 @@ class AppointmentRequestListView(generics.ListAPIView): 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): - availability = AdminWeeklyAvailability.objects.first() - if availability: - available_days = availability.available_days - preferred_dates = serializer.validated_data['preferred_dates'] - - for date_str in preferred_dates: - date_obj = datetime.strptime(date_str, '%Y-%m-%d').date() - if date_obj.weekday() not in available_days: - from rest_framework.exceptions import ValidationError - raise ValidationError(f'Date {date_str} is not available for appointments.') - appointment = serializer.save() - EmailService.send_admin_notification(appointment) + + 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] @@ -65,6 +82,7 @@ class AppointmentRequestDetailView(generics.RetrieveAPIView): serializer_class = AppointmentRequestSerializer lookup_field = 'pk' + class ScheduleAppointmentView(generics.GenericAPIView): permission_classes = [IsAuthenticated, IsAdminUser] serializer_class = AppointmentScheduleSerializer @@ -123,27 +141,98 @@ class RejectAppointmentView(generics.GenericAPIView): response_serializer = AppointmentRequestSerializer(appointment) return Response(response_serializer.data) - + class AvailableDatesView(generics.GenericAPIView): permission_classes = [AllowAny] def get(self, request): - availability = AdminWeeklyAvailability.objects.first() - if not availability: + availability = get_admin_availability() + if not availability or not availability.availability_schedule: return Response([]) - available_days = availability.available_days today = timezone.now().date() available_dates = [] - for i in range(1, 31): + for i in range(1, 31): date = today + timedelta(days=i) - if date.weekday() in available_days: - available_dates.append(date.strftime('%Y-%m-%d')) + 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 = AppointmentRequestSerializer @@ -152,7 +241,7 @@ class UserAppointmentsView(generics.ListAPIView): return AppointmentRequest.objects.filter( email=self.request.user.email ).order_by('-created_at') - + class AppointmentStatsView(generics.GenericAPIView): permission_classes = [IsAuthenticated, IsAdminUser] @@ -163,21 +252,39 @@ class AppointmentStatsView(generics.GenericAPIView): 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() + + 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 + '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 }) - + + class UserAppointmentStatsView(generics.GenericAPIView): permission_classes = [IsAuthenticated] def post(self, request): - email = request.data.get('email') + email = request.data.get('email', self.request.user.email) + + if not self.request.user.is_staff and email != self.request.user.email: + return Response( + {'error': 'You can only view your own statistics'}, + status=status.HTTP_403_FORBIDDEN + ) + stats = AppointmentRequest.objects.filter( email=email ).aggregate( @@ -198,5 +305,77 @@ class UserAppointmentStatsView(generics.GenericAPIView): 'scheduled': scheduled, 'rejected': stats['rejected'], 'completed': stats['completed'], - 'completion_rate': completion_rate - }) \ No newline at end of file + 'completion_rate': completion_rate, + 'email': email + }) + + +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 + ) + + +@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 \ No newline at end of file