From eb54d1784cf95e1c57da72c61779f1639e505651 Mon Sep 17 00:00:00 2001 From: saani Date: Fri, 5 Dec 2025 10:34:19 +0000 Subject: [PATCH] feat: enhance appointment scheduling with user timezone support and email reminders --- booking_system/views.py | 7 +- meetings/email_service.py | 84 ++++- meetings/migrations/0001_initial.py | 68 +++++ meetings/models.py | 1 + meetings/serializers.py | 1 + meetings/tasks.py | 4 - meetings/urls.py | 48 --- meetings/views.py | 321 ++++---------------- requirements.txt | Bin 1736 -> 1764 bytes templates/emails/appointment_scheduled.html | 18 +- templatetags/__init__.py | 0 templatetags/timezone_filters.py | 46 +++ users/migrations/0001_initial.py | 72 +++++ 13 files changed, 343 insertions(+), 327 deletions(-) create mode 100644 meetings/migrations/0001_initial.py create mode 100644 templatetags/__init__.py create mode 100644 templatetags/timezone_filters.py create mode 100644 users/migrations/0001_initial.py diff --git a/booking_system/views.py b/booking_system/views.py index 3edbb47..fb54fa5 100644 --- a/booking_system/views.py +++ b/booking_system/views.py @@ -323,7 +323,12 @@ def api_root(request, format=None): "prerequisites": "Appointment must be in 'pending_review' status", "scheduling_options": { "direct_datetime": { - "example": {"scheduled_datetime": "2024-01-15T10:00:00Z", "scheduled_duration": 60} + "example": { + "scheduled_datetime": "2025-12-05T10:30:00Z", + "scheduled_duration": 30, + "timezone": "America/New_York", + "create_jitsi_meeting": "true" + } }, "date_and_slot": { "example": {"date_str": "2024-01-15", "time_slot": "morning", "scheduled_duration": 60} diff --git a/meetings/email_service.py b/meetings/email_service.py index eaec3e4..285d750 100644 --- a/meetings/email_service.py +++ b/meetings/email_service.py @@ -1,9 +1,32 @@ from django.core.mail import EmailMultiAlternatives from django.template.loader import render_to_string from django.conf import settings +from django.utils import timezone +import pytz +from datetime import datetime class EmailService: + @staticmethod + def _format_datetime_for_user(dt, user_timezone='UTC'): + if not dt: + return '' + + try: + user_tz = pytz.timezone(user_timezone or 'UTC') + + if timezone.is_naive(dt): + dt = timezone.make_aware(dt, pytz.UTC) + + local_dt = dt.astimezone(user_tz) + + formatted = local_dt.strftime('%B %d, %Y at %I:%M %p %Z') + + return formatted + except Exception as e: + print(f"Error formatting datetime: {e}") + return dt.strftime('%B %d, %Y at %I:%M %p UTC') + @staticmethod def send_admin_notification(appointment): subject = f"New Appointment Request from {appointment.full_name}" @@ -22,7 +45,7 @@ class EmailService: try: email = EmailMultiAlternatives( subject=subject, - body="Please view this email in an HTML-compatible client.", # Fallback text + body="Please view this email in an HTML-compatible client.", from_email=settings.DEFAULT_FROM_EMAIL, to=[admin_email], ) @@ -37,10 +60,20 @@ class EmailService: def send_appointment_scheduled(appointment): subject = "Your Appointment Has Been Scheduled" + user_timezone = getattr(appointment, 'user_timezone', 'UTC') + + formatted_datetime = EmailService._format_datetime_for_user( + appointment.scheduled_datetime, + user_timezone + ) + context = { 'appointment': appointment, - 'scheduled_datetime': appointment.formatted_scheduled_datetime, - 'user_dashboard_url': f"{settings.FRONTEND_URL}/dashboard" if hasattr(settings, 'FRONTEND_URL') else '/dashboard/' + '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_scheduled.html', context) @@ -48,7 +81,7 @@ class EmailService: try: email = EmailMultiAlternatives( subject=subject, - body="Please view this email in an HTML-compatible client.", # Fallback text + body=f"Your appointment has been scheduled for {formatted_datetime}. Please view this email in an HTML-compatible client for more details.", from_email=settings.DEFAULT_FROM_EMAIL, to=[appointment.email], ) @@ -66,7 +99,10 @@ class EmailService: context = { 'appointment': appointment, 'rejection_reason': appointment.rejection_reason or "No specific reason provided.", - 'user_dashboard_url': f"{settings.FRONTEND_URL}/dashboard" if hasattr(settings, 'FRONTEND_URL') else '/dashboard/' + '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_rejected.html', context) @@ -74,7 +110,7 @@ class EmailService: try: email = EmailMultiAlternatives( subject=subject, - body="Please view this email in an HTML-compatible client.", # Fallback text + body="Please view this email in an HTML-compatible client.", from_email=settings.DEFAULT_FROM_EMAIL, to=[appointment.email], ) @@ -83,4 +119,40 @@ class EmailService: return True except Exception as e: print(f"Failed to send rejection notification: {e}") + return False + + @staticmethod + def send_appointment_reminder(appointment, hours_before=24): + subject = "Reminder: Your Upcoming Appointment" + + 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, + 'hours_before': hours_before, + 'join_url': appointment.get_participant_join_url() if hasattr(appointment, 'get_participant_join_url') else None, + 'settings': { + 'SITE_NAME': getattr(settings, 'SITE_NAME', 'Attune Heart Therapy') + } + } + + html_message = render_to_string('emails/appointment_reminder.html', context) + + try: + email = EmailMultiAlternatives( + subject=subject, + body=f"This is a reminder that you have an appointment scheduled for {formatted_datetime}.", + 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 reminder notification: {e}") return False \ No newline at end of file diff --git a/meetings/migrations/0001_initial.py b/meetings/migrations/0001_initial.py new file mode 100644 index 0000000..bca8a25 --- /dev/null +++ b/meetings/migrations/0001_initial.py @@ -0,0 +1,68 @@ +# Generated by Django 5.2.8 on 2025-12-05 09:48 + +import meetings.models +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='AdminWeeklyAvailability', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('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)), + ], + options={ + 'verbose_name': 'Admin Weekly Availability', + 'verbose_name_plural': 'Admin Weekly Availability', + }, + ), + migrations.CreateModel( + name='AppointmentRequest', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('first_name', models.CharField(max_length=255)), + ('last_name', models.CharField(max_length=255)), + ('email', models.EmailField(max_length=255)), + ('phone', models.CharField(blank=True, max_length=50, null=True)), + ('reason', models.TextField(blank=True, help_text='Reason for appointment', null=True)), + ('preferred_dates', models.JSONField(help_text='List of preferred dates (YYYY-MM-DD format)')), + ('preferred_time_slots', models.JSONField(help_text='List of preferred time slots (morning/afternoon/evening)')), + ('status', models.CharField(choices=[('pending_review', 'Pending Review'), ('scheduled', 'Scheduled'), ('rejected', 'Rejected'), ('completed', 'Completed'), ('cancelled', 'Cancelled')], default='pending_review', max_length=20)), + ('scheduled_datetime', models.DateTimeField(blank=True, null=True)), + ('user_timezone', models.CharField(blank=True, default='UTC', max_length=63)), + ('scheduled_duration', models.PositiveIntegerField(default=30, help_text='Duration in minutes')), + ('rejection_reason', meetings.models.EncryptedTextField(blank=True, null=True)), + ('jitsi_meet_url', models.URLField(blank=True, help_text='Jitsi Meet URL for the video session', max_length=2000, null=True, unique=True)), + ('jitsi_room_id', models.CharField(blank=True, help_text='Jitsi room ID', max_length=255, null=True, unique=True)), + ('jitsi_meeting_created', models.BooleanField(default=False)), + ('jitsi_moderator_token', models.TextField(blank=True, null=True)), + ('jitsi_participant_token', models.TextField(blank=True, null=True)), + ('jitsi_meeting_password', models.CharField(blank=True, max_length=255, null=True)), + ('jitsi_meeting_config', models.JSONField(default=dict, help_text='Jitsi meeting configuration and settings')), + ('jitsi_recording_url', models.URLField(blank=True, help_text='URL to meeting recording', null=True)), + ('jitsi_meeting_data', models.JSONField(default=dict, 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(blank=True, null=True)), + ('meeting_ended_at', models.DateTimeField(blank=True, null=True)), + ('meeting_duration_actual', models.PositiveIntegerField(default=0, help_text='Actual meeting duration in minutes')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'verbose_name': 'Appointment Request', + 'verbose_name_plural': 'Appointment Requests', + 'ordering': ['-created_at'], + 'indexes': [models.Index(fields=['status', 'scheduled_datetime'], name='meetings_ap_status_4e4e26_idx'), models.Index(fields=['email', 'created_at'], name='meetings_ap_email_b8ed9d_idx'), models.Index(fields=['jitsi_meeting_created', 'scheduled_datetime'], name='meetings_ap_jitsi_m_f3c488_idx'), models.Index(fields=['meeting_started_at'], name='meetings_ap_meeting_157142_idx')], + }, + ), + ] diff --git a/meetings/models.py b/meetings/models.py index e0b9606..498d1c8 100644 --- a/meetings/models.py +++ b/meetings/models.py @@ -205,6 +205,7 @@ class AppointmentRequest(models.Model): ) scheduled_datetime = models.DateTimeField(null=True, blank=True) + user_timezone = models.CharField(max_length=63, default='UTC', blank=True) scheduled_duration = models.PositiveIntegerField( default=30, help_text="Duration in minutes" diff --git a/meetings/serializers.py b/meetings/serializers.py index a248824..1757563 100644 --- a/meetings/serializers.py +++ b/meetings/serializers.py @@ -333,6 +333,7 @@ class AppointmentScheduleSerializer(serializers.Serializer): time_slot = serializers.CharField(required=False, write_only=True) create_jitsi_meeting = serializers.BooleanField(default=True) jitsi_custom_config = serializers.JSONField(required=False, default=dict) + timezone = serializers.CharField(required=False, default='UTC') def validate(self, data): scheduled_datetime = data.get('scheduled_datetime') diff --git a/meetings/tasks.py b/meetings/tasks.py index 01601de..6b51f5a 100644 --- a/meetings/tasks.py +++ b/meetings/tasks.py @@ -53,9 +53,6 @@ def send_booking_notification_email(booking_id): logger.error(f"Failed to send booking notification email: {str(e)}") def send_booking_confirmation_email(booking_id): - """ - Send beautiful confirmation email when booking is confirmed - """ try: from .models import TherapyBooking booking = TherapyBooking.objects.get(id=booking_id) @@ -64,7 +61,6 @@ def send_booking_confirmation_email(booking_id): subject = f"✅ Appointment Confirmed - {booking.get_appointment_type_display()} - {booking.confirmed_datetime.strftime('%b %d')}" - # Render professional HTML template html_message = render_to_string('emails/booking_confirmed.html', { 'booking': booking, 'payment_url': f"https://attunehearttherapy.com/payment/{booking.id}", diff --git a/meetings/urls.py b/meetings/urls.py index cdd9a0a..f98a647 100644 --- a/meetings/urls.py +++ b/meetings/urls.py @@ -15,10 +15,7 @@ from .views import ( UserAppointmentStatsView, MatchingAvailabilityView, JoinMeetingView, - MeetingActionView, - UpcomingMeetingsView, MeetingAnalyticsView, - BulkMeetingActionsView, availability_overview, EndMeetingView, StartMeetingView @@ -51,58 +48,13 @@ urlpatterns = [ path('appointments/stats/', AppointmentStatsView.as_view(), name='appointment-stats'), - # Meeting Join URLs - path('appointments//join-meeting/', JoinMeetingView.as_view(), name='join-meeting'), - path('appointments//join-meeting/participant/', - JoinMeetingView.as_view(), {'user_type': 'participant'}, name='join-meeting-participant'), - path('appointments//join-meeting/moderator/', - JoinMeetingView.as_view(), {'user_type': 'moderator'}, name='join-meeting-moderator'), - - # Meeting Actions - path('appointments//meeting-actions/', MeetingActionView.as_view(), name='meeting-actions'), - path('appointments//start-meeting/', - MeetingActionView.as_view(), {'action': 'start'}, name='start-meeting'), - path('appointments//end-meeting/', - MeetingActionView.as_view(), {'action': 'end'}, name='end-meeting'), - path('appointments//update-meeting-metadata/', - MeetingActionView.as_view(), {'action': 'update_metadata'}, name='update-meeting-metadata'), - path('appointments//save-recording/', - MeetingActionView.as_view(), {'action': 'record'}, name='save-recording'), - - # Meeting Views - path('meetings/upcoming/', UpcomingMeetingsView.as_view(), name='upcoming-meetings'), - path('meetings/today/', UpcomingMeetingsView.as_view(), {'today_only': True}, name='today-meetings'), - path('meetings/past/', UpcomingMeetingsView.as_view(), {'past_only': True}, name='past-meetings'), - # Meeting Analytics path('appointments//meeting-analytics/', MeetingAnalyticsView.as_view(), name='meeting-analytics'), path('meetings/analytics/summary/', MeetingAnalyticsView.as_view(), {'summary': True}, name='meeting-analytics-summary'), - # Bulk Meeting Operations - path('meetings/bulk-actions/', BulkMeetingActionsView.as_view(), name='bulk-meeting-actions'), - path('meetings/bulk-create/', - BulkMeetingActionsView.as_view(), {'action': 'create_jitsi_meetings'}, name='bulk-create-meetings'), - path('meetings/bulk-send-reminders/', - BulkMeetingActionsView.as_view(), {'action': 'send_reminders'}, name='bulk-send-reminders'), - path('meetings/bulk-end-old/', - BulkMeetingActionsView.as_view(), {'action': 'end_old_meetings'}, name='bulk-end-old-meetings'), - - # Meeting Quick Actions (simplified endpoints) - path('meetings//quick-join/', - JoinMeetingView.as_view(), {'quick_join': True}, name='quick-join-meeting'), - path('meetings//quick-join/patient/', - JoinMeetingView.as_view(), {'quick_join': True, 'user_type': 'participant'}, name='quick-join-patient'), - path('meetings//quick-join/therapist/', - JoinMeetingView.as_view(), {'quick_join': True, 'user_type': 'moderator'}, name='quick-join-therapist'), - # Meeting Status & Info - path('meetings//status/', MeetingActionView.as_view(), {'get_status': True}, name='meeting-status'), path('meetings//info/', JoinMeetingView.as_view(), {'info_only': True}, name='meeting-info'), path('meetings//end/', EndMeetingView.as_view(), name='end-meeting-simple'), path('meetings//start/', StartMeetingView.as_view(), name='start-meeting-simple'), - - # Meeting Webhook/Notification endpoints (for Jitsi callbacks) - path('meetings/webhook/jitsi/', MeetingActionView.as_view(), {'webhook': True}, name='jitsi-webhook'), - path('meetings/recording-callback/', MeetingActionView.as_view(), {'recording_callback': True}, name='recording-callback'), ] \ No newline at end of file diff --git a/meetings/views.py b/meetings/views.py index d9d2896..f8b0e14 100644 --- a/meetings/views.py +++ b/meetings/views.py @@ -14,18 +14,13 @@ from .serializers import ( AppointmentScheduleSerializer, AppointmentRejectSerializer, AvailabilityCheckSerializer, - AvailabilityResponseSerializer, - WeeklyAvailabilitySerializer, AdminAvailabilityConfigSerializer, AppointmentDetailSerializer, - JitsiMeetingSerializer, MeetingJoinSerializer, - MeetingActionSerializer + MeetingActionSerializer, ) from .email_service import EmailService from users.models import CustomUser -from django.db.models import Count, Q -import hashlib class AdminAvailabilityView(generics.RetrieveUpdateAPIView): @@ -94,66 +89,70 @@ class ScheduleAppointmentView(generics.GenericAPIView): lookup_field = 'pk' def post(self, request, pk): - appointment = self.get_object() + try: + appointment = self.get_object() - if appointment.status != 'pending_review': - return Response( - {'error': 'Only pending review appointments can be scheduled.'}, - status=status.HTTP_400_BAD_REQUEST - ) - - serializer = self.get_serializer(data=request.data) - serializer.is_valid(raise_exception=True) - - create_jitsi_meeting = serializer.validated_data.get('create_jitsi_meeting', True) - jitsi_custom_config = serializer.validated_data.get('jitsi_custom_config', {}) - - admin_user = request.user - - appointment.schedule_appointment( - datetime_obj=serializer.validated_data['scheduled_datetime'], - duration=serializer.validated_data['scheduled_duration'], - create_meeting=create_jitsi_meeting, - moderator_user=admin_user, - commit=False - ) - if jitsi_custom_config and create_jitsi_meeting: - if appointment.has_jitsi_meeting: - appointment.jitsi_meeting_config.update(jitsi_custom_config) - else: - appointment.create_jitsi_meeting( - moderator_user=admin_user, - with_moderation=True, - custom_config=jitsi_custom_config + if appointment.status != 'pending_review': + return Response( + {'error': 'Only pending review appointments can be scheduled.'}, + status=status.HTTP_400_BAD_REQUEST ) - - appointment.save() - - EmailService.send_appointment_scheduled(appointment) - - response_serializer = AppointmentDetailSerializer(appointment) - - # Try to get participant user from appointment email - participant_user = None - if appointment.email: - try: - participant_user = CustomUser.objects.get(email=appointment.email) - except CustomUser.DoesNotExist: - participant_user = None - - return Response({ - **response_serializer.data, - 'message': 'Appointment scheduled successfully.', - 'jitsi_meeting_created': appointment.has_jitsi_meeting, - 'moderator_name': admin_user.get_full_name() or admin_user.username, - 'moderator_join_url': appointment.get_moderator_join_url( - moderator_user=admin_user - ) if appointment.has_jitsi_meeting else None, - 'participant_join_url': appointment.get_participant_join_url( - participant_user=participant_user - ) if appointment.has_jitsi_meeting and participant_user else None, - }) - + + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + + create_jitsi_meeting = serializer.validated_data.get('create_jitsi_meeting', True) + jitsi_custom_config = serializer.validated_data.get('jitsi_custom_config', {}) + user_timezone = serializer.validated_data.get('timezone', 'UTC') + + appointment.user_timezone = user_timezone + + admin_user = request.user + + appointment.schedule_appointment( + datetime_obj=serializer.validated_data['scheduled_datetime'], + duration=serializer.validated_data['scheduled_duration'], + create_meeting=create_jitsi_meeting, + moderator_user=admin_user, + commit=False + ) + + if jitsi_custom_config and create_jitsi_meeting: + if appointment.has_jitsi_meeting: + appointment.jitsi_meeting_config.update(jitsi_custom_config) + else: + appointment.create_jitsi_meeting( + moderator_user=admin_user, + with_moderation=True, + custom_config=jitsi_custom_config + ) + + appointment.save() + + EmailService.send_appointment_scheduled(appointment) + + response_serializer = AppointmentDetailSerializer(appointment) + participant_user = None + if appointment.email: + try: + participant_user = CustomUser.objects.get(email=appointment.email) + except CustomUser.DoesNotExist: + participant_user = None + + return Response({ + **response_serializer.data, + 'message': 'Appointment scheduled successfully.', + 'jitsi_meeting_created': appointment.has_jitsi_meeting, + 'moderator_name': admin_user.get_full_name() or admin_user.username, + 'moderator_join_url': appointment.get_moderator_join_url( + moderator_user=admin_user + ) if appointment.has_jitsi_meeting else None, + 'participant_join_url': appointment.get_participant_join_url( + participant_user=participant_user + ) if appointment.has_jitsi_meeting and participant_user else None, + }) + except Exception as e: + return Response({'error': str(e)}, status=status.HTTP_400_BAD_REQUEST) class RejectAppointmentView(generics.GenericAPIView): permission_classes = [IsAuthenticated] @@ -569,122 +568,6 @@ class JoinMeetingView(generics.GenericAPIView): } -class MeetingActionView(generics.GenericAPIView): - permission_classes = [IsAuthenticated, IsAdminUser] - serializer_class = MeetingActionSerializer - queryset = AppointmentRequest.objects.all() - lookup_field = 'pk' - - def post(self, request, pk): - appointment = self.get_object() - - if appointment.status != 'scheduled': - return Response( - {'error': 'Meeting actions only available for scheduled appointments'}, - status=status.HTTP_400_BAD_REQUEST - ) - - serializer = self.get_serializer(data=request.data) - serializer.is_valid(raise_exception=True) - - action = serializer.validated_data['action'] - - if action == 'start': - appointment.start_meeting() - message = 'Meeting started successfully' - - elif action == 'end': - appointment.end_meeting() - message = 'Meeting ended successfully' - - elif action == 'update_metadata': - metadata = serializer.validated_data.get('metadata', {}) - appointment.update_meeting_data(metadata) - message = 'Meeting metadata updated successfully' - - elif action == 'record': - recording_url = serializer.validated_data.get('recording_url', '') - if recording_url: - appointment.jitsi_recording_url = recording_url - appointment.save(update_fields=['jitsi_recording_url']) - message = 'Recording URL saved successfully' - else: - return Response( - {'error': 'Recording URL is required for record action'}, - status=status.HTTP_400_BAD_REQUEST - ) - - elif action == 'allow_participants': - appointment.is_admin_join = True - appointment.save(update_fields=['is_admin_join', 'updated_at']) - message = 'Participants can now join the meeting' - - elif action == 'disallow_participants': - appointment.is_admin_join = False - appointment.save(update_fields=['is_admin_join', 'updated_at']) - message = 'Participants are no longer allowed to join the meeting' - - return Response({ - 'appointment_id': str(appointment.id), - 'action': action, - 'message': message, - 'meeting_status': appointment.status, - 'started_at': appointment.meeting_started_at, - 'ended_at': appointment.meeting_ended_at, - 'is_admin_join': appointment.is_admin_join, - }) - - -class UpcomingMeetingsView(generics.ListAPIView): - permission_classes = [IsAuthenticated] - serializer_class = AppointmentDetailSerializer - - def get_queryset(self): - queryset = AppointmentRequest.objects.filter( - status='scheduled', - scheduled_datetime__gt=timezone.now() - ).order_by('scheduled_datetime') - - if not self.request.user.is_staff: - user_email = self.request.user.email.lower() - all_appointments = list(queryset) - matching_appointments = [ - apt for apt in all_appointments - if apt.email and apt.email.lower() == user_email - ] - appointment_ids = [apt.id for apt in matching_appointments] - queryset = queryset.filter(id__in=appointment_ids) - - return queryset - - def list(self, request, *args, **kwargs): - response = super().list(request, *args, **kwargs) - queryset = self.get_queryset() - now = timezone.now() - - upcoming_meetings = [] - for meeting in queryset: - meeting_data = { - 'id': str(meeting.id), - 'title': f"Session with {meeting.full_name}", - 'start': meeting.scheduled_datetime.isoformat(), - 'end': (meeting.scheduled_datetime + timedelta(minutes=meeting.scheduled_duration)).isoformat(), - 'can_join': meeting.can_join_meeting('moderator' if request.user.is_staff else 'participant'), - 'has_video': meeting.has_jitsi_meeting, - 'status': meeting.get_meeting_status(), - } - upcoming_meetings.append(meeting_data) - - response.data = { - 'upcoming_meetings': upcoming_meetings, - 'total_upcoming': queryset.count(), - 'next_meeting': upcoming_meetings[0] if upcoming_meetings else None, - 'now': now.isoformat(), - } - - return response - - class MeetingAnalyticsView(generics.RetrieveAPIView): permission_classes = [IsAuthenticated, IsAdminUser] queryset = AppointmentRequest.objects.all() @@ -715,84 +598,6 @@ class MeetingAnalyticsView(generics.RetrieveAPIView): }) -class BulkMeetingActionsView(generics.GenericAPIView): - permission_classes = [IsAuthenticated, IsAdminUser] - - def post(self, request): - action = request.data.get('action') - appointment_ids = request.data.get('appointment_ids', []) - - if not action or not appointment_ids: - return Response( - {'error': 'Action and appointment_ids are required'}, - status=status.HTTP_400_BAD_REQUEST - ) - - valid_actions = ['create_jitsi_meetings', 'send_reminders', 'end_old_meetings'] - if action not in valid_actions: - return Response( - {'error': f'Invalid action. Must be one of: {valid_actions}'}, - status=status.HTTP_400_BAD_REQUEST - ) - - appointments = AppointmentRequest.objects.filter( - id__in=appointment_ids, - status='scheduled' - ) - - results = [] - - if action == 'create_jitsi_meetings': - for appointment in appointments: - if not appointment.has_jitsi_meeting: - appointment.create_jitsi_meeting() - results.append({ - 'id': str(appointment.id), - 'action': 'meeting_created', - 'success': True, - 'message': f'Jitsi meeting created for {appointment.full_name}', - }) - else: - results.append({ - 'id': str(appointment.id), - 'action': 'already_created', - 'success': True, - 'message': f'Jitsi meeting already exists for {appointment.full_name}', - }) - - elif action == 'send_reminders': - for appointment in appointments: - if appointment.has_jitsi_meeting and appointment.meeting_in_future: - EmailService.send_meeting_reminder(appointment) - results.append({ - 'id': str(appointment.id), - 'action': 'reminder_sent', - 'success': True, - 'message': f'Reminder sent to {appointment.full_name}', - }) - - elif action == 'end_old_meetings': - for appointment in appointments: - if appointment.meeting_started_at and not appointment.meeting_ended_at: - scheduled_end = appointment.scheduled_datetime + timedelta(minutes=appointment.scheduled_duration) - buffer_end = scheduled_end + timedelta(minutes=30) - - if timezone.now() > buffer_end: - appointment.end_meeting() - results.append({ - 'id': str(appointment.id), - 'action': 'meeting_ended', - 'success': True, - 'message': f'Meeting ended for {appointment.full_name}', - }) - - return Response({ - 'action': action, - 'total_processed': len(results), - 'results': results, - }) - - @api_view(['GET']) @permission_classes([AllowAny]) def availability_overview(request): diff --git a/requirements.txt b/requirements.txt index 32b3809df62a5c45edd6a0e9ebc4e80e1126b334..7b69061bfc256c06c8cae5be0fb8823ba0142c78 100644 GIT binary patch delta 36 pcmX@X`-FGHBNp)jhDwGKhAIYIAT(ky0Af=HJqDxAcUfLD0sy}O32p!Y delta 12 UcmaFDdxCevBbLqoSe`Kg04Syf`2YX_ diff --git a/templates/emails/appointment_scheduled.html b/templates/emails/appointment_scheduled.html index 5773ccc..0083d92 100644 --- a/templates/emails/appointment_scheduled.html +++ b/templates/emails/appointment_scheduled.html @@ -256,7 +256,6 @@

Your therapy session has been scheduled

-