From f73fc31a0e0fd3f7b3014958e2f4f257e754bfcf Mon Sep 17 00:00:00 2001 From: saani Date: Fri, 5 Dec 2025 18:04:11 +0000 Subject: [PATCH] feat: implement appointment rescheduling and cancellation features with email notifications --- booking_system/views.py | 39 +++ meetings/email_service.py | 36 +++ meetings/models.py | 1 - meetings/serializers.py | 33 +- meetings/urls.py | 6 +- meetings/views.py | 59 ++++ templates/emails/appointment_rescheduled.html | 294 ++++++++++++++++++ 7 files changed, 462 insertions(+), 6 deletions(-) create mode 100644 templates/emails/appointment_rescheduled.html diff --git a/booking_system/views.py b/booking_system/views.py index 4ad28c4..e7dfa69 100644 --- a/booking_system/views.py +++ b/booking_system/views.py @@ -350,6 +350,33 @@ def api_root(request, format=None): "has_jitsi_meeting": "true" } }, + "reschedule_appointment": { + "description": "Reschedule an existing appointment (Admin only)", + "url": request.build_absolute_uri("/api/meetings/appointments//reschedule/"), + "methods": ["POST"], + "authentication": "Required (Staff users only)", + "required_fields": ["scheduled_datetime"], + "optional_fields": ["scheduled_duration", "date_str", "time_slot"], + "prerequisites": "Appointment must be in 'scheduled' status", + "scheduling_options": { + "direct_datetime": { + "example": { + "scheduled_datetime": "2025-12-10T11:00:00Z", + "scheduled_duration": 45, + "timezone": timezone.get_current_timezone_name() + } + }, + "date_and_slot": { + "example": {"date_str": "2024-01-20", "time_slot": "afternoon", "scheduled_duration": 60} + } + }, + "validation": "Validates against admin availability when using date_str + time_slot", + "side_effects": [ + "Updates scheduled_datetime and scheduled_duration", + "Clears Jitsi meeting information", + "Sends rescheduled email to user" + ] + }, "reject_appointment": { "description": "Reject an appointment request (Admin only)", "url": request.build_absolute_uri("/api/meetings/appointments//reject/"), @@ -390,6 +417,18 @@ def api_root(request, format=None): "Sends completion email to user" ] }, + "cancel_meeting": { + "description": "Cancel a scheduled appointment and its Jitsi meeting", + "url": request.build_absolute_uri("/api/meetings/appointments//cancel/"), + "methods": ["POST"], + "authentication": "Required", + "prerequisites": "Appointment must be in 'scheduled' or 'active' status", + "side_effects": [ + "Updates meeting status to 'cancelled'", + "Clears Jitsi meeting information", + "Sends cancellation email to user", + ] + }, "appointment_stats": { "description": "Get appointment statistics and analytics with availability metrics (Admin only)", diff --git a/meetings/email_service.py b/meetings/email_service.py index 285d750..cd40dcb 100644 --- a/meetings/email_service.py +++ b/meetings/email_service.py @@ -91,6 +91,42 @@ class EmailService: except Exception as e: print(f"Failed to send scheduled notification: {e}") return False + + @staticmethod + def send_appointment_rescheduled(appointment): + subject = "Your Appointment Has Been Rescheduled" + + user_timezone = getattr(appointment, 'user_timezone', 'UTC') + + formatted_datetime = EmailService._format_datetime_for_user( + appointment.scheduled_datetime, + user_timezone + ) + + context = { + 'appointment': appointment, + 'scheduled_datetime': formatted_datetime, + 'user_dashboard_url': f"{settings.FRONTEND_URL}/dashboard" if hasattr(settings, 'FRONTEND_URL') else '/dashboard/', + 'settings': { + 'SITE_NAME': getattr(settings, 'SITE_NAME', 'Attune Heart Therapy') + } + } + + html_message = render_to_string('emails/appointment_rescheduled.html', context) + + try: + email = EmailMultiAlternatives( + subject=subject, + body=f"Your appointment has been rescheduled for {formatted_datetime}. Please view this email in an HTML-compatible client for more details.", + from_email=settings.DEFAULT_FROM_EMAIL, + to=[appointment.email], + ) + email.attach_alternative(html_message, "text/html") + email.send(fail_silently=False) + return True + except Exception as e: + print(f"Failed to send rescheduled notification: {e}") + return False @staticmethod def send_appointment_rejected(appointment): diff --git a/meetings/models.py b/meetings/models.py index 21dd421..622961b 100644 --- a/meetings/models.py +++ b/meetings/models.py @@ -662,7 +662,6 @@ class AppointmentRequest(models.Model): def cancel_appointment(self, reason='', commit=True): self.status = 'cancelled' - self.rejection_reason = reason if commit: self.save() diff --git a/meetings/serializers.py b/meetings/serializers.py index 0c6258d..dd4bece 100644 --- a/meetings/serializers.py +++ b/meetings/serializers.py @@ -242,7 +242,6 @@ class AppointmentRequestCreateSerializer(serializers.ModelSerializer): ZoneInfo(value) return value except Exception: - # If invalid, default to UTC but don't raise error return 'UTC' def validate(self, data): @@ -323,13 +322,10 @@ class AppointmentRequestCreateSerializer(serializers.ModelSerializer): return time_slots def create(self, validated_data): - # Extract timezone before creating timezone = validated_data.pop('timezone', 'UTC') - # Create appointment appointment = super().create(validated_data) - # Set timezone on the created appointment appointment.user_timezone = timezone appointment.save(update_fields=['user_timezone']) @@ -438,6 +434,35 @@ class AppointmentScheduleSerializer(serializers.Serializer): return representation +class RecheduleAppointmentSerializer(serializers.Serializer): + new_scheduled_datetime = serializers.DateTimeField() + new_scheduled_duration = serializers.IntegerField(default=60, min_value=30, max_value=240) + timezone = serializers.CharField(required=False, default='UTC') + + def validate_new_scheduled_datetime(self, value): + if value <= timezone.now(): + raise serializers.ValidationError("New scheduled datetime must be in the future.") + return value + + def validate_new_scheduled_duration(self, value): + if value < 30: + raise serializers.ValidationError("Duration must be at least 30 minutes.") + if value > 240: + raise serializers.ValidationError("Duration cannot exceed 4 hours.") + return value + + def save(self, appointment): + new_datetime = self.validated_data['new_scheduled_datetime'] + new_duration = self.validated_data.get('new_scheduled_duration', 60) + + appointment.reschedule_appointment( + new_datetime=new_datetime, + new_duration=new_duration, + commit=True + ) + + return appointment + class AppointmentDetailSerializer(serializers.ModelSerializer): meeting_info = serializers.SerializerMethodField() meeting_analytics = serializers.SerializerMethodField() diff --git a/meetings/urls.py b/meetings/urls.py index f98a647..fc9b754 100644 --- a/meetings/urls.py +++ b/meetings/urls.py @@ -18,7 +18,9 @@ from .views import ( MeetingAnalyticsView, availability_overview, EndMeetingView, - StartMeetingView + StartMeetingView, + CancelMeetingView, + RescheduleAppointmentView ) urlpatterns = [ @@ -33,6 +35,7 @@ urlpatterns = [ # Appointment Request URLs path('appointments/', AppointmentRequestListView.as_view(), name='appointment-list'), path('appointments/create/', AppointmentRequestCreateView.as_view(), name='appointment-create'), + path('appointments//reschedule/', RescheduleAppointmentView.as_view(), name='reschedule-appointment'), path('appointments//', AppointmentRequestDetailView.as_view(), name='appointment-detail'), path('appointments//matching-availability/', MatchingAvailabilityView.as_view(), name='matching-availability'), @@ -57,4 +60,5 @@ urlpatterns = [ path('meetings//end/', EndMeetingView.as_view(), name='end-meeting-simple'), path('meetings//start/', StartMeetingView.as_view(), name='start-meeting-simple'), + path('meetings//cancel/', CancelMeetingView.as_view(), name='cancel-meeting'), ] \ No newline at end of file diff --git a/meetings/views.py b/meetings/views.py index 9e20b25..879cfbc 100644 --- a/meetings/views.py +++ b/meetings/views.py @@ -18,6 +18,7 @@ from .serializers import ( AppointmentDetailSerializer, MeetingJoinSerializer, MeetingActionSerializer, + RecheduleAppointmentSerializer ) from .email_service import EmailService from users.models import CustomUser @@ -180,6 +181,36 @@ class RejectAppointmentView(generics.GenericAPIView): response_serializer = AppointmentDetailSerializer(appointment) return Response(response_serializer.data) +class RescheduleAppointmentView(generics.GenericAPIView): + permission_classes = [IsAuthenticated, IsAdminUser] + serializer_class = RecheduleAppointmentSerializer + queryset = AppointmentRequest.objects.all() + lookup_field = 'pk' + + def post(self, request, pk): + appointment = self.get_object() + + if appointment.status != 'scheduled': + return Response( + {'error': 'Only scheduled appointments can be rescheduled'}, + status=status.HTTP_400_BAD_REQUEST + ) + + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + + new_datetime = serializer.validated_data['new_scheduled_datetime'] + new_duration = serializer.validated_data.get('new_scheduled_duration', appointment.scheduled_duration) + + appointment.reschedule_appointment( + new_datetime=new_datetime, + new_duration=new_duration + ) + + EmailService.send_appointment_rescheduled(appointment) + + response_serializer = AppointmentDetailSerializer(appointment) + return Response(response_serializer.data) class AvailableDatesView(generics.GenericAPIView): permission_classes = [AllowAny] @@ -478,6 +509,34 @@ class EndMeetingView(generics.GenericAPIView): 'meeting_ended_at': appointment.meeting_ended_at, }) +class CancelMeetingView(generics.GenericAPIView): + permission_classes = [IsAuthenticated] + serializer_class = MeetingActionSerializer + queryset = AppointmentRequest.objects.all() + lookup_field = 'pk' + + def post(self, request, pk): + appointment = self.get_object() + + if not request.user.is_staff: + return Response( + {'error': 'Only staff members can cancel meetings'}, + status=status.HTTP_403_FORBIDDEN + ) + + if appointment.status != 'scheduled': + return Response( + {'error': 'Only scheduled appointments can be canceled'}, + status=status.HTTP_400_BAD_REQUEST + ) + + appointment.cancel_appointment() + + return Response({ + 'appointment_id': str(appointment.id), + 'message': 'Meeting canceled successfully', + }) + class JoinMeetingView(generics.GenericAPIView): permission_classes = [IsAuthenticated] serializer_class = MeetingJoinSerializer diff --git a/templates/emails/appointment_rescheduled.html b/templates/emails/appointment_rescheduled.html new file mode 100644 index 0000000..57993a1 --- /dev/null +++ b/templates/emails/appointment_rescheduled.html @@ -0,0 +1,294 @@ + + + + + + Appointment Confirmed + + + + + +