Compare commits
No commits in common. "28e232b5dc956a9c25ca0007ab5b0db83f006ded" and "63f8be59e85c18b6feb1512f881d291e094005e0" have entirely different histories.
28e232b5dc
...
63f8be59e8
@ -350,33 +350,6 @@ def api_root(request, format=None):
|
||||
"has_jitsi_meeting": "true"
|
||||
}
|
||||
},
|
||||
"reschedule_appointment": {
|
||||
"description": "Reschedule an existing appointment (Admin only)",
|
||||
"url": request.build_absolute_uri("/api/meetings/appointments/<uuid:pk>/reschedule/"),
|
||||
"methods": ["POST"],
|
||||
"authentication": "Required (Staff users only)",
|
||||
"required_fields": ["scheduled_datetime"],
|
||||
"optional_fields": ["scheduled_duration", "date_str", "time_slot"],
|
||||
"prerequisites": "Appointment must be in 'scheduled' status",
|
||||
"scheduling_options": {
|
||||
"direct_datetime": {
|
||||
"example": {
|
||||
"scheduled_datetime": "2025-12-10T11:00:00Z",
|
||||
"scheduled_duration": 45,
|
||||
"timezone": timezone.get_current_timezone_name()
|
||||
}
|
||||
},
|
||||
"date_and_slot": {
|
||||
"example": {"date_str": "2024-01-20", "time_slot": "afternoon", "scheduled_duration": 60}
|
||||
}
|
||||
},
|
||||
"validation": "Validates against admin availability when using date_str + time_slot",
|
||||
"side_effects": [
|
||||
"Updates scheduled_datetime and scheduled_duration",
|
||||
"Clears Jitsi meeting information",
|
||||
"Sends rescheduled email to user"
|
||||
]
|
||||
},
|
||||
"reject_appointment": {
|
||||
"description": "Reject an appointment request (Admin only)",
|
||||
"url": request.build_absolute_uri("/api/meetings/appointments/<uuid:pk>/reject/"),
|
||||
@ -417,18 +390,6 @@ def api_root(request, format=None):
|
||||
"Sends completion email to user"
|
||||
]
|
||||
},
|
||||
"cancel_meeting": {
|
||||
"description": "Cancel a scheduled appointment and its Jitsi meeting",
|
||||
"url": request.build_absolute_uri("/api/meetings/appointments/<uuid:pk>/cancel/"),
|
||||
"methods": ["POST"],
|
||||
"authentication": "Required",
|
||||
"prerequisites": "Appointment must be in 'scheduled' or 'active' status",
|
||||
"side_effects": [
|
||||
"Updates meeting status to 'cancelled'",
|
||||
"Clears Jitsi meeting information",
|
||||
"Sends cancellation email to user",
|
||||
]
|
||||
},
|
||||
|
||||
"appointment_stats": {
|
||||
"description": "Get appointment statistics and analytics with availability metrics (Admin only)",
|
||||
|
||||
@ -92,42 +92,6 @@ class EmailService:
|
||||
print(f"Failed to send scheduled notification: {e}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def send_appointment_rescheduled(appointment):
|
||||
subject = "Your Appointment Has Been Rescheduled"
|
||||
|
||||
user_timezone = getattr(appointment, 'user_timezone', 'UTC')
|
||||
|
||||
formatted_datetime = EmailService._format_datetime_for_user(
|
||||
appointment.scheduled_datetime,
|
||||
user_timezone
|
||||
)
|
||||
|
||||
context = {
|
||||
'appointment': appointment,
|
||||
'scheduled_datetime': formatted_datetime,
|
||||
'user_dashboard_url': f"{settings.FRONTEND_URL}/dashboard" if hasattr(settings, 'FRONTEND_URL') else '/dashboard/',
|
||||
'settings': {
|
||||
'SITE_NAME': getattr(settings, 'SITE_NAME', 'Attune Heart Therapy')
|
||||
}
|
||||
}
|
||||
|
||||
html_message = render_to_string('emails/appointment_rescheduled.html', context)
|
||||
|
||||
try:
|
||||
email = EmailMultiAlternatives(
|
||||
subject=subject,
|
||||
body=f"Your appointment has been rescheduled for {formatted_datetime}. Please view this email in an HTML-compatible client for more details.",
|
||||
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||
to=[appointment.email],
|
||||
)
|
||||
email.attach_alternative(html_message, "text/html")
|
||||
email.send(fail_silently=False)
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Failed to send rescheduled notification: {e}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def send_appointment_rejected(appointment):
|
||||
subject = "Update on Your Appointment Request"
|
||||
|
||||
@ -662,6 +662,7 @@ class AppointmentRequest(models.Model):
|
||||
|
||||
def cancel_appointment(self, reason='', commit=True):
|
||||
self.status = 'cancelled'
|
||||
self.rejection_reason = reason
|
||||
if commit:
|
||||
self.save()
|
||||
|
||||
|
||||
@ -242,6 +242,7 @@ class AppointmentRequestCreateSerializer(serializers.ModelSerializer):
|
||||
ZoneInfo(value)
|
||||
return value
|
||||
except Exception:
|
||||
# If invalid, default to UTC but don't raise error
|
||||
return 'UTC'
|
||||
|
||||
def validate(self, data):
|
||||
@ -322,10 +323,13 @@ class AppointmentRequestCreateSerializer(serializers.ModelSerializer):
|
||||
return time_slots
|
||||
|
||||
def create(self, validated_data):
|
||||
# Extract timezone before creating
|
||||
timezone = validated_data.pop('timezone', 'UTC')
|
||||
|
||||
# Create appointment
|
||||
appointment = super().create(validated_data)
|
||||
|
||||
# Set timezone on the created appointment
|
||||
appointment.user_timezone = timezone
|
||||
appointment.save(update_fields=['user_timezone'])
|
||||
|
||||
@ -434,35 +438,6 @@ class AppointmentScheduleSerializer(serializers.Serializer):
|
||||
|
||||
return representation
|
||||
|
||||
class RecheduleAppointmentSerializer(serializers.Serializer):
|
||||
new_scheduled_datetime = serializers.DateTimeField()
|
||||
new_scheduled_duration = serializers.IntegerField(default=60, min_value=30, max_value=240)
|
||||
timezone = serializers.CharField(required=False, default='UTC')
|
||||
|
||||
def validate_new_scheduled_datetime(self, value):
|
||||
if value <= timezone.now():
|
||||
raise serializers.ValidationError("New scheduled datetime must be in the future.")
|
||||
return value
|
||||
|
||||
def validate_new_scheduled_duration(self, value):
|
||||
if value < 30:
|
||||
raise serializers.ValidationError("Duration must be at least 30 minutes.")
|
||||
if value > 240:
|
||||
raise serializers.ValidationError("Duration cannot exceed 4 hours.")
|
||||
return value
|
||||
|
||||
def save(self, appointment):
|
||||
new_datetime = self.validated_data['new_scheduled_datetime']
|
||||
new_duration = self.validated_data.get('new_scheduled_duration', 60)
|
||||
|
||||
appointment.reschedule_appointment(
|
||||
new_datetime=new_datetime,
|
||||
new_duration=new_duration,
|
||||
commit=True
|
||||
)
|
||||
|
||||
return appointment
|
||||
|
||||
class AppointmentDetailSerializer(serializers.ModelSerializer):
|
||||
meeting_info = serializers.SerializerMethodField()
|
||||
meeting_analytics = serializers.SerializerMethodField()
|
||||
|
||||
@ -18,9 +18,7 @@ from .views import (
|
||||
MeetingAnalyticsView,
|
||||
availability_overview,
|
||||
EndMeetingView,
|
||||
StartMeetingView,
|
||||
CancelMeetingView,
|
||||
RescheduleAppointmentView
|
||||
StartMeetingView
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
@ -35,7 +33,6 @@ urlpatterns = [
|
||||
# Appointment Request URLs
|
||||
path('appointments/', AppointmentRequestListView.as_view(), name='appointment-list'),
|
||||
path('appointments/create/', AppointmentRequestCreateView.as_view(), name='appointment-create'),
|
||||
path('appointments/<uuid:pk>/reschedule/', RescheduleAppointmentView.as_view(), name='reschedule-appointment'),
|
||||
path('appointments/<uuid:pk>/', AppointmentRequestDetailView.as_view(), name='appointment-detail'),
|
||||
path('appointments/<uuid:pk>/matching-availability/', MatchingAvailabilityView.as_view(), name='matching-availability'),
|
||||
|
||||
@ -60,5 +57,4 @@ urlpatterns = [
|
||||
|
||||
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>/cancel/', CancelMeetingView.as_view(), name='cancel-meeting'),
|
||||
]
|
||||
@ -18,7 +18,6 @@ from .serializers import (
|
||||
AppointmentDetailSerializer,
|
||||
MeetingJoinSerializer,
|
||||
MeetingActionSerializer,
|
||||
RecheduleAppointmentSerializer
|
||||
)
|
||||
from .email_service import EmailService
|
||||
from users.models import CustomUser
|
||||
@ -181,36 +180,6 @@ class RejectAppointmentView(generics.GenericAPIView):
|
||||
response_serializer = AppointmentDetailSerializer(appointment)
|
||||
return Response(response_serializer.data)
|
||||
|
||||
class RescheduleAppointmentView(generics.GenericAPIView):
|
||||
permission_classes = [IsAuthenticated, IsAdminUser]
|
||||
serializer_class = RecheduleAppointmentSerializer
|
||||
queryset = AppointmentRequest.objects.all()
|
||||
lookup_field = 'pk'
|
||||
|
||||
def post(self, request, pk):
|
||||
appointment = self.get_object()
|
||||
|
||||
if appointment.status != 'scheduled':
|
||||
return Response(
|
||||
{'error': 'Only scheduled appointments can be rescheduled'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
new_datetime = serializer.validated_data['new_scheduled_datetime']
|
||||
new_duration = serializer.validated_data.get('new_scheduled_duration', appointment.scheduled_duration)
|
||||
|
||||
appointment.reschedule_appointment(
|
||||
new_datetime=new_datetime,
|
||||
new_duration=new_duration
|
||||
)
|
||||
|
||||
EmailService.send_appointment_rescheduled(appointment)
|
||||
|
||||
response_serializer = AppointmentDetailSerializer(appointment)
|
||||
return Response(response_serializer.data)
|
||||
|
||||
class AvailableDatesView(generics.GenericAPIView):
|
||||
permission_classes = [AllowAny]
|
||||
@ -509,34 +478,6 @@ class EndMeetingView(generics.GenericAPIView):
|
||||
'meeting_ended_at': appointment.meeting_ended_at,
|
||||
})
|
||||
|
||||
class CancelMeetingView(generics.GenericAPIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
serializer_class = MeetingActionSerializer
|
||||
queryset = AppointmentRequest.objects.all()
|
||||
lookup_field = 'pk'
|
||||
|
||||
def post(self, request, pk):
|
||||
appointment = self.get_object()
|
||||
|
||||
if not request.user.is_staff:
|
||||
return Response(
|
||||
{'error': 'Only staff members can cancel meetings'},
|
||||
status=status.HTTP_403_FORBIDDEN
|
||||
)
|
||||
|
||||
if appointment.status != 'scheduled':
|
||||
return Response(
|
||||
{'error': 'Only scheduled appointments can be canceled'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
appointment.cancel_appointment()
|
||||
|
||||
return Response({
|
||||
'appointment_id': str(appointment.id),
|
||||
'message': 'Meeting canceled successfully',
|
||||
})
|
||||
|
||||
class JoinMeetingView(generics.GenericAPIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
serializer_class = MeetingJoinSerializer
|
||||
|
||||
@ -1,294 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Appointment Confirmed</title>
|
||||
<style>
|
||||
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap");
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI",
|
||||
Roboto, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
background-color: #f8fafc;
|
||||
}
|
||||
|
||||
.email-container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
background: #ffffff;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1),
|
||||
0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.email-header {
|
||||
background-image: linear-gradient(to right, #e11d48, #db2777, #f97316);
|
||||
padding: 40px 30px;
|
||||
text-align: center;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.email-header h1 {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.email-header p {
|
||||
font-size: 16px;
|
||||
opacity: 0.9;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.email-body {
|
||||
padding: 40px 30px;
|
||||
}
|
||||
|
||||
.greeting {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin-bottom: 30px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.confirmation-card {
|
||||
background: linear-gradient(135deg, #c6f6d5 0%, #9ae6b4 100%);
|
||||
border-radius: 16px;
|
||||
padding: 30px;
|
||||
text-align: center;
|
||||
margin: 25px 0;
|
||||
border: 2px dashed #38a169;
|
||||
}
|
||||
|
||||
.confirmation-title {
|
||||
font-size: 24px;
|
||||
color: #22543d;
|
||||
margin-bottom: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.appointment-time {
|
||||
font-size: 28px;
|
||||
color: #22543d;
|
||||
font-weight: 700;
|
||||
margin: 15px 0;
|
||||
}
|
||||
|
||||
.therapist-info {
|
||||
color: #2d3748;
|
||||
font-size: 16px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.details-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 20px;
|
||||
margin: 30px 0;
|
||||
}
|
||||
|
||||
.detail-card {
|
||||
background: #f7fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.detail-icon {
|
||||
font-size: 24px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
font-weight: 600;
|
||||
color: #4a5568;
|
||||
font-size: 14px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
color: #2d3748;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.preparation {
|
||||
background: #fffaf0;
|
||||
border-radius: 12px;
|
||||
padding: 25px;
|
||||
margin: 25px 0;
|
||||
border-left: 4px solid #ed8936;
|
||||
}
|
||||
|
||||
.prep-title {
|
||||
font-weight: 600;
|
||||
color: #744210;
|
||||
margin-bottom: 15px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.prep-list {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.prep-list li {
|
||||
margin-bottom: 10px;
|
||||
padding-left: 25px;
|
||||
position: relative;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.prep-list li:before {
|
||||
content: "✓";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
color: #48bb78;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.contact-info {
|
||||
background: #edf2f7;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
text-align: center;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.action-section {
|
||||
text-align: center;
|
||||
margin: 35px 0;
|
||||
}
|
||||
|
||||
.button {
|
||||
display: inline-block;
|
||||
background-image: linear-gradient(to right, #e11d48, #db2777, #f97316);
|
||||
color: white;
|
||||
padding: 16px 32px;
|
||||
text-decoration: none;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.login-info {
|
||||
color: #2d3748;
|
||||
font-size: 16px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.email-footer {
|
||||
background-image: linear-gradient(to right, #e11d48, #db2777, #f97316);
|
||||
padding: 30px;
|
||||
text-align: center;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.footer-text {
|
||||
font-size: 14px;
|
||||
margin-bottom: 10px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.copyright {
|
||||
font-size: 12px;
|
||||
margin-top: 15px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.email-container {
|
||||
margin: 10px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.email-header,
|
||||
.email-body,
|
||||
.email-footer {
|
||||
padding: 25px 20px;
|
||||
}
|
||||
|
||||
.details-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.confirmation-card {
|
||||
padding: 25px 20px;
|
||||
}
|
||||
|
||||
.appointment-time {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
font-size: 36px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="email-container">
|
||||
<div class="email-header">
|
||||
<h1>Appointment Time Updated!</h1>
|
||||
<p>Your therapy session has been rescheduled</p>
|
||||
</div>
|
||||
|
||||
<div class="email-body">
|
||||
<div class="greeting">
|
||||
Hello <strong>{{ appointment.full_name }}</strong>,<br />
|
||||
Great news! Your appointment request has been confirmed. We're looking
|
||||
forward to seeing you.
|
||||
</div>
|
||||
|
||||
<div class="confirmation-card">
|
||||
<div class="confirmation-title">Your Appointment Details</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 log in to your account at https://attuneHeartTherapy.com 15 minutes early to join your Therapy Session.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="email-footer">
|
||||
<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"
|
||||
style="color: #fff; text-decoration: none"
|
||||
>admin@attunehearttherapy.com</a>
|
||||
</p>
|
||||
<p class="copyright">
|
||||
© {% now "Y" %} {{ settings.SITE_NAME }}. All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Reference in New Issue
Block a user