feat: enhance appointment scheduling with user timezone support and email reminders

This commit is contained in:
saani 2025-12-05 10:34:19 +00:00
parent 6fe52b4998
commit eb54d1784c
13 changed files with 343 additions and 327 deletions

View File

@ -323,7 +323,12 @@ def api_root(request, format=None):
"prerequisites": "Appointment must be in 'pending_review' status", "prerequisites": "Appointment must be in 'pending_review' status",
"scheduling_options": { "scheduling_options": {
"direct_datetime": { "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": { "date_and_slot": {
"example": {"date_str": "2024-01-15", "time_slot": "morning", "scheduled_duration": 60} "example": {"date_str": "2024-01-15", "time_slot": "morning", "scheduled_duration": 60}

View File

@ -1,9 +1,32 @@
from django.core.mail import EmailMultiAlternatives from django.core.mail import EmailMultiAlternatives
from django.template.loader import render_to_string from django.template.loader import render_to_string
from django.conf import settings from django.conf import settings
from django.utils import timezone
import pytz
from datetime import datetime
class EmailService: 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 @staticmethod
def send_admin_notification(appointment): def send_admin_notification(appointment):
subject = f"New Appointment Request from {appointment.full_name}" subject = f"New Appointment Request from {appointment.full_name}"
@ -22,7 +45,7 @@ class EmailService:
try: try:
email = EmailMultiAlternatives( email = EmailMultiAlternatives(
subject=subject, 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, from_email=settings.DEFAULT_FROM_EMAIL,
to=[admin_email], to=[admin_email],
) )
@ -37,10 +60,20 @@ class EmailService:
def send_appointment_scheduled(appointment): def send_appointment_scheduled(appointment):
subject = "Your Appointment Has Been Scheduled" 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 = { context = {
'appointment': appointment, 'appointment': appointment,
'scheduled_datetime': appointment.formatted_scheduled_datetime, 'scheduled_datetime': formatted_datetime,
'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_scheduled.html', context) html_message = render_to_string('emails/appointment_scheduled.html', context)
@ -48,7 +81,7 @@ class EmailService:
try: try:
email = EmailMultiAlternatives( email = EmailMultiAlternatives(
subject=subject, 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, from_email=settings.DEFAULT_FROM_EMAIL,
to=[appointment.email], to=[appointment.email],
) )
@ -66,7 +99,10 @@ class EmailService:
context = { context = {
'appointment': appointment, 'appointment': appointment,
'rejection_reason': appointment.rejection_reason or "No specific reason provided.", '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) html_message = render_to_string('emails/appointment_rejected.html', context)
@ -74,7 +110,7 @@ class EmailService:
try: try:
email = EmailMultiAlternatives( email = EmailMultiAlternatives(
subject=subject, 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, from_email=settings.DEFAULT_FROM_EMAIL,
to=[appointment.email], to=[appointment.email],
) )
@ -83,4 +119,40 @@ class EmailService:
return True return True
except Exception as e: except Exception as e:
print(f"Failed to send rejection notification: {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 return False

View File

@ -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')],
},
),
]

View File

@ -205,6 +205,7 @@ class AppointmentRequest(models.Model):
) )
scheduled_datetime = models.DateTimeField(null=True, blank=True) scheduled_datetime = models.DateTimeField(null=True, blank=True)
user_timezone = models.CharField(max_length=63, default='UTC', blank=True)
scheduled_duration = models.PositiveIntegerField( scheduled_duration = models.PositiveIntegerField(
default=30, default=30,
help_text="Duration in minutes" help_text="Duration in minutes"

View File

@ -333,6 +333,7 @@ class AppointmentScheduleSerializer(serializers.Serializer):
time_slot = serializers.CharField(required=False, write_only=True) time_slot = serializers.CharField(required=False, write_only=True)
create_jitsi_meeting = serializers.BooleanField(default=True) create_jitsi_meeting = serializers.BooleanField(default=True)
jitsi_custom_config = serializers.JSONField(required=False, default=dict) jitsi_custom_config = serializers.JSONField(required=False, default=dict)
timezone = serializers.CharField(required=False, default='UTC')
def validate(self, data): def validate(self, data):
scheduled_datetime = data.get('scheduled_datetime') scheduled_datetime = data.get('scheduled_datetime')

View File

@ -53,9 +53,6 @@ def send_booking_notification_email(booking_id):
logger.error(f"Failed to send booking notification email: {str(e)}") logger.error(f"Failed to send booking notification email: {str(e)}")
def send_booking_confirmation_email(booking_id): def send_booking_confirmation_email(booking_id):
"""
Send beautiful confirmation email when booking is confirmed
"""
try: try:
from .models import TherapyBooking from .models import TherapyBooking
booking = TherapyBooking.objects.get(id=booking_id) 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')}" 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', { html_message = render_to_string('emails/booking_confirmed.html', {
'booking': booking, 'booking': booking,
'payment_url': f"https://attunehearttherapy.com/payment/{booking.id}", 'payment_url': f"https://attunehearttherapy.com/payment/{booking.id}",

View File

@ -15,10 +15,7 @@ from .views import (
UserAppointmentStatsView, UserAppointmentStatsView,
MatchingAvailabilityView, MatchingAvailabilityView,
JoinMeetingView, JoinMeetingView,
MeetingActionView,
UpcomingMeetingsView,
MeetingAnalyticsView, MeetingAnalyticsView,
BulkMeetingActionsView,
availability_overview, availability_overview,
EndMeetingView, EndMeetingView,
StartMeetingView StartMeetingView
@ -51,58 +48,13 @@ urlpatterns = [
path('appointments/stats/', AppointmentStatsView.as_view(), name='appointment-stats'), path('appointments/stats/', AppointmentStatsView.as_view(), name='appointment-stats'),
# Meeting Join URLs
path('appointments/<uuid:pk>/join-meeting/', JoinMeetingView.as_view(), name='join-meeting'),
path('appointments/<uuid:pk>/join-meeting/participant/',
JoinMeetingView.as_view(), {'user_type': 'participant'}, name='join-meeting-participant'),
path('appointments/<uuid:pk>/join-meeting/moderator/',
JoinMeetingView.as_view(), {'user_type': 'moderator'}, name='join-meeting-moderator'),
# Meeting Actions
path('appointments/<uuid:pk>/meeting-actions/', MeetingActionView.as_view(), name='meeting-actions'),
path('appointments/<uuid:pk>/start-meeting/',
MeetingActionView.as_view(), {'action': 'start'}, name='start-meeting'),
path('appointments/<uuid:pk>/end-meeting/',
MeetingActionView.as_view(), {'action': 'end'}, name='end-meeting'),
path('appointments/<uuid:pk>/update-meeting-metadata/',
MeetingActionView.as_view(), {'action': 'update_metadata'}, name='update-meeting-metadata'),
path('appointments/<uuid:pk>/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 # Meeting Analytics
path('appointments/<uuid:pk>/meeting-analytics/', MeetingAnalyticsView.as_view(), name='meeting-analytics'), path('appointments/<uuid:pk>/meeting-analytics/', MeetingAnalyticsView.as_view(), name='meeting-analytics'),
path('meetings/analytics/summary/', MeetingAnalyticsView.as_view(), {'summary': True}, name='meeting-analytics-summary'), 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/<uuid:pk>/quick-join/',
JoinMeetingView.as_view(), {'quick_join': True}, name='quick-join-meeting'),
path('meetings/<uuid:pk>/quick-join/patient/',
JoinMeetingView.as_view(), {'quick_join': True, 'user_type': 'participant'}, name='quick-join-patient'),
path('meetings/<uuid:pk>/quick-join/therapist/',
JoinMeetingView.as_view(), {'quick_join': True, 'user_type': 'moderator'}, name='quick-join-therapist'),
# Meeting Status & Info # Meeting Status & Info
path('meetings/<uuid:pk>/status/', MeetingActionView.as_view(), {'get_status': True}, name='meeting-status'),
path('meetings/<uuid:pk>/info/', JoinMeetingView.as_view(), {'info_only': True}, name='meeting-info'), path('meetings/<uuid:pk>/info/', JoinMeetingView.as_view(), {'info_only': True}, name='meeting-info'),
path('meetings/<uuid:pk>/end/', EndMeetingView.as_view(), name='end-meeting-simple'), path('meetings/<uuid:pk>/end/', EndMeetingView.as_view(), name='end-meeting-simple'),
path('meetings/<uuid:pk>/start/', StartMeetingView.as_view(), name='start-meeting-simple'), path('meetings/<uuid:pk>/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'),
] ]

View File

@ -14,18 +14,13 @@ from .serializers import (
AppointmentScheduleSerializer, AppointmentScheduleSerializer,
AppointmentRejectSerializer, AppointmentRejectSerializer,
AvailabilityCheckSerializer, AvailabilityCheckSerializer,
AvailabilityResponseSerializer,
WeeklyAvailabilitySerializer,
AdminAvailabilityConfigSerializer, AdminAvailabilityConfigSerializer,
AppointmentDetailSerializer, AppointmentDetailSerializer,
JitsiMeetingSerializer,
MeetingJoinSerializer, MeetingJoinSerializer,
MeetingActionSerializer MeetingActionSerializer,
) )
from .email_service import EmailService from .email_service import EmailService
from users.models import CustomUser from users.models import CustomUser
from django.db.models import Count, Q
import hashlib
class AdminAvailabilityView(generics.RetrieveUpdateAPIView): class AdminAvailabilityView(generics.RetrieveUpdateAPIView):
@ -94,66 +89,70 @@ class ScheduleAppointmentView(generics.GenericAPIView):
lookup_field = 'pk' lookup_field = 'pk'
def post(self, request, pk): def post(self, request, pk):
appointment = self.get_object() try:
appointment = self.get_object()
if appointment.status != 'pending_review': if appointment.status != 'pending_review':
return Response( return Response(
{'error': 'Only pending review appointments can be scheduled.'}, {'error': 'Only pending review appointments can be scheduled.'},
status=status.HTTP_400_BAD_REQUEST 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
) )
appointment.save() serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
EmailService.send_appointment_scheduled(appointment)
create_jitsi_meeting = serializer.validated_data.get('create_jitsi_meeting', True)
response_serializer = AppointmentDetailSerializer(appointment) jitsi_custom_config = serializer.validated_data.get('jitsi_custom_config', {})
user_timezone = serializer.validated_data.get('timezone', 'UTC')
# Try to get participant user from appointment email
participant_user = None appointment.user_timezone = user_timezone
if appointment.email:
try: admin_user = request.user
participant_user = CustomUser.objects.get(email=appointment.email)
except CustomUser.DoesNotExist: appointment.schedule_appointment(
participant_user = None datetime_obj=serializer.validated_data['scheduled_datetime'],
duration=serializer.validated_data['scheduled_duration'],
return Response({ create_meeting=create_jitsi_meeting,
**response_serializer.data, moderator_user=admin_user,
'message': 'Appointment scheduled successfully.', commit=False
'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( if jitsi_custom_config and create_jitsi_meeting:
moderator_user=admin_user if appointment.has_jitsi_meeting:
) if appointment.has_jitsi_meeting else None, appointment.jitsi_meeting_config.update(jitsi_custom_config)
'participant_join_url': appointment.get_participant_join_url( else:
participant_user=participant_user appointment.create_jitsi_meeting(
) if appointment.has_jitsi_meeting and participant_user else None, 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): class RejectAppointmentView(generics.GenericAPIView):
permission_classes = [IsAuthenticated] 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): class MeetingAnalyticsView(generics.RetrieveAPIView):
permission_classes = [IsAuthenticated, IsAdminUser] permission_classes = [IsAuthenticated, IsAdminUser]
queryset = AppointmentRequest.objects.all() 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']) @api_view(['GET'])
@permission_classes([AllowAny]) @permission_classes([AllowAny])
def availability_overview(request): def availability_overview(request):

Binary file not shown.

View File

@ -256,7 +256,6 @@
<p>Your therapy session has been scheduled</p> <p>Your therapy session has been scheduled</p>
</div> </div>
<!-- Body -->
<div class="email-body"> <div class="email-body">
<div class="greeting"> <div class="greeting">
Hello <strong>{{ appointment.full_name }}</strong>,<br /> Hello <strong>{{ appointment.full_name }}</strong>,<br />
@ -264,10 +263,11 @@
forward to seeing you. forward to seeing you.
</div> </div>
<!-- Confirmation Card -->
<div class="confirmation-card"> <div class="confirmation-card">
<div class="confirmation-title">Your Appointment Details</div> <div class="confirmation-title">Your Appointment Details</div>
<div class="appointment-time">{{ scheduled_datetime }}</div> <div class="appointment-time">
{{ scheduled_datetime }}
</div>
<div class="therapist-info">With: Nathalie (Therapist)</div> <div class="therapist-info">With: Nathalie (Therapist)</div>
<div class="login-info"> <div class="login-info">
On the day of your appointment, please login to your account at www.AttuneHeartTherapy.com 15 minutes early to join your Therapy Session. On the day of your appointment, please login to your account at www.AttuneHeartTherapy.com 15 minutes early to join your Therapy Session.
@ -276,19 +276,17 @@
</div> </div>
<!-- Footer -->
<div class="email-footer"> <div class="email-footer">
<div class="company-name">{{ settings.SITE_NAME|default:"Attune Heart Therapy" }}</div> <div class="company-name">{{ settings.SITE_NAME }}</div>
<p class="support-info"> <p class="support-info">
Need help? Contact our support team at Need help? Contact our support team at
<a
href="mailto:admin@attunehearttherapy.com" <a href="mailto:admin@attunehearttherapy.com"
style="color: #fff; text-decoration: none" style="color: #fff; text-decoration: none"
>admin@attunehearttherapy.com</a >admin@attunehearttherapy.com</a>
>
</p> </p>
<p class="copyright"> <p class="copyright">
© {% now "Y" %} {{ settings.SITE_NAME|default:"Attune Heart Therapy" }}. All rights reserved. © {% now "Y" %} {{ settings.SITE_NAME }}. All rights reserved.
</p> </p>
</div> </div>
</div> </div>

0
templatetags/__init__.py Normal file
View File

View File

@ -0,0 +1,46 @@
from django import template
from django.utils import timezone
import pytz
register = template.Library()
@register.filter
def to_user_timezone(dt, tz_string='UTC'):
if not dt:
return ''
try:
user_tz = pytz.timezone(tz_string or 'UTC')
if timezone.is_naive(dt):
dt = timezone.make_aware(dt, pytz.UTC)
local_dt = dt.astimezone(user_tz)
return local_dt.strftime('%B %d, %Y at %I:%M %p %Z')
except Exception as e:
return dt.strftime('%B %d, %Y at %I:%M %p UTC')
@register.filter
def to_user_timezone_short(dt, tz_string='UTC'):
if not dt:
return ''
try:
user_tz = pytz.timezone(tz_string or 'UTC')
if timezone.is_naive(dt):
dt = timezone.make_aware(dt, pytz.UTC)
local_dt = dt.astimezone(user_tz)
return local_dt.strftime('%b %d, %Y %I:%M %p')
except Exception as e:
return dt.strftime('%b %d, %Y %I:%M %p')
@register.filter
def timezone_abbr(tz_string='UTC'):
try:
from datetime import datetime
tz = pytz.timezone(tz_string or 'UTC')
return datetime.now(tz).strftime('%Z')
except:
return 'UTC'

View File

@ -0,0 +1,72 @@
# Generated by Django 5.2.8 on 2025-12-05 09:48
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('auth', '0012_alter_user_first_name_max_length'),
]
operations = [
migrations.CreateModel(
name='ContactMessage',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255)),
('email', models.EmailField(max_length=254)),
('phone', models.CharField(blank=True, max_length=20)),
('message', models.TextField()),
('created_at', models.DateTimeField(auto_now_add=True)),
('is_read', models.BooleanField(default=False)),
('is_responded', models.BooleanField(default=False)),
],
options={
'verbose_name': 'Contact Message',
'verbose_name_plural': 'Contact Messages',
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='CustomUser',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('password', models.CharField(max_length=128, verbose_name='password')),
('email', models.EmailField(max_length=254, unique=True)),
('first_name', models.CharField(max_length=50)),
('last_name', models.CharField(max_length=50)),
('is_staff', models.BooleanField(default=False)),
('is_superuser', models.BooleanField(default=False)),
('is_active', models.BooleanField(default=True)),
('isVerified', models.BooleanField(default=False)),
('verify_otp', models.CharField(blank=True, max_length=6, null=True)),
('verify_otp_expiry', models.DateTimeField(blank=True, null=True)),
('forgot_password_otp', models.CharField(blank=True, max_length=6, null=True)),
('forgot_password_otp_expiry', models.DateTimeField(blank=True, null=True)),
('phone_number', models.CharField(blank=True, max_length=20)),
('last_login', models.DateTimeField(auto_now=True)),
('date_joined', models.DateTimeField(auto_now_add=True)),
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='UserProfile',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('bio', models.TextField(blank=True, max_length=500)),
('timezone', models.CharField(default='UTC', max_length=50)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='profile', to=settings.AUTH_USER_MODEL)),
],
),
]