Compare commits
3 Commits
4f8c8bb909
...
889f98d28b
| Author | SHA1 | Date | |
|---|---|---|---|
| 889f98d28b | |||
| 6311a737eb | |||
| eb54d1784c |
@ -1,6 +1,8 @@
|
||||
from rest_framework.decorators import api_view, permission_classes
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.permissions import AllowAny
|
||||
from django.utils import timezone
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
@permission_classes([AllowAny])
|
||||
@ -323,7 +325,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": timezone.get_current_timezone_name(),
|
||||
"create_jitsi_meeting": "true"
|
||||
}
|
||||
},
|
||||
"date_and_slot": {
|
||||
"example": {"date_str": "2024-01-15", "time_slot": "morning", "scheduled_duration": 60}
|
||||
|
||||
@ -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],
|
||||
)
|
||||
@ -84,3 +120,39 @@ class EmailService:
|
||||
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
|
||||
68
meetings/migrations/0001_initial.py
Normal file
68
meetings/migrations/0001_initial.py
Normal 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')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -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"
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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}",
|
||||
|
||||
@ -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/<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
|
||||
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'),
|
||||
|
||||
# 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
|
||||
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>/end/', EndMeetingView.as_view(), name='end-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'),
|
||||
]
|
||||
@ -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()
|
||||
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)
|
||||
jitsi_custom_config = serializer.validated_data.get('jitsi_custom_config', {})
|
||||
user_timezone = serializer.validated_data.get('timezone', 'UTC')
|
||||
|
||||
response_serializer = AppointmentDetailSerializer(appointment)
|
||||
appointment.user_timezone = user_timezone
|
||||
|
||||
# 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
|
||||
admin_user = request.user
|
||||
|
||||
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,
|
||||
})
|
||||
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):
|
||||
|
||||
BIN
requirements.txt
BIN
requirements.txt
Binary file not shown.
@ -256,7 +256,6 @@
|
||||
<p>Your therapy session has been scheduled</p>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="email-body">
|
||||
<div class="greeting">
|
||||
Hello <strong>{{ appointment.full_name }}</strong>,<br />
|
||||
@ -264,10 +263,11 @@
|
||||
forward to seeing you.
|
||||
</div>
|
||||
|
||||
<!-- Confirmation Card -->
|
||||
<div class="confirmation-card">
|
||||
<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="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.
|
||||
@ -276,19 +276,17 @@
|
||||
|
||||
</div>
|
||||
|
||||
<!-- 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">
|
||||
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"
|
||||
>admin@attunehearttherapy.com</a
|
||||
>
|
||||
>admin@attunehearttherapy.com</a>
|
||||
</p>
|
||||
<p class="copyright">
|
||||
© {% now "Y" %} {{ settings.SITE_NAME|default:"Attune Heart Therapy" }}. All rights reserved.
|
||||
© {% now "Y" %} {{ settings.SITE_NAME }}. All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
0
templatetags/__init__.py
Normal file
0
templatetags/__init__.py
Normal file
46
templatetags/timezone_filters.py
Normal file
46
templatetags/timezone_filters.py
Normal 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'
|
||||
72
users/migrations/0001_initial.py
Normal file
72
users/migrations/0001_initial.py
Normal 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)),
|
||||
],
|
||||
),
|
||||
]
|
||||
Loading…
Reference in New Issue
Block a user