Compare commits

..

No commits in common. "28e232b5dc956a9c25ca0007ab5b0db83f006ded" and "63f8be59e85c18b6feb1512f881d291e094005e0" have entirely different histories.

7 changed files with 6 additions and 462 deletions

View File

@ -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)",

View File

@ -91,42 +91,6 @@ class EmailService:
except Exception as e:
print(f"Failed to send scheduled notification: {e}")
return False
@staticmethod
def send_appointment_rescheduled(appointment):
subject = "Your Appointment Has Been Rescheduled"
user_timezone = getattr(appointment, 'user_timezone', 'UTC')
formatted_datetime = EmailService._format_datetime_for_user(
appointment.scheduled_datetime,
user_timezone
)
context = {
'appointment': appointment,
'scheduled_datetime': formatted_datetime,
'user_dashboard_url': f"{settings.FRONTEND_URL}/dashboard" if hasattr(settings, 'FRONTEND_URL') else '/dashboard/',
'settings': {
'SITE_NAME': getattr(settings, 'SITE_NAME', 'Attune Heart Therapy')
}
}
html_message = render_to_string('emails/appointment_rescheduled.html', context)
try:
email = EmailMultiAlternatives(
subject=subject,
body=f"Your appointment has been rescheduled for {formatted_datetime}. Please view this email in an HTML-compatible client for more details.",
from_email=settings.DEFAULT_FROM_EMAIL,
to=[appointment.email],
)
email.attach_alternative(html_message, "text/html")
email.send(fail_silently=False)
return True
except Exception as e:
print(f"Failed to send rescheduled notification: {e}")
return False
@staticmethod
def send_appointment_rejected(appointment):

View File

@ -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()

View File

@ -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()

View File

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

View File

@ -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

View File

@ -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>