Merge pull request 'feature/meetings' (#7) from feature/meetings into main

Reviewed-on: https://gitea.blackbusinesslabs.com/ATTUNE-HEART-THERAPY/alternative-backend-service/pulls/7
This commit is contained in:
Saani 2025-11-23 00:28:30 +00:00
commit bac26a0487
27 changed files with 2378 additions and 665 deletions

2
.gitignore vendored
View File

@ -127,8 +127,6 @@ __pypackages__/
celerybeat-schedule celerybeat-schedule
celerybeat.pid celerybeat.pid
meetings
# SageMath parsed files # SageMath parsed files
*.sage.py *.sage.py

View File

@ -34,7 +34,7 @@ INSTALLED_APPS = [
'corsheaders', 'corsheaders',
'users', 'users',
# 'meetings', 'meetings',
] ]
MIDDLEWARE = [ MIDDLEWARE = [
@ -48,6 +48,8 @@ MIDDLEWARE = [
'django.middleware.clickjacking.XFrameOptionsMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware',
] ]
SITE_NAME = os.getenv('SITE_NAME', 'Attune Heart Therapy')
ROOT_URLCONF = 'booking_system.urls' ROOT_URLCONF = 'booking_system.urls'
TEMPLATES = [ TEMPLATES = [
@ -70,23 +72,25 @@ TEMPLATES = [
WSGI_APPLICATION = 'booking_system.wsgi.application' WSGI_APPLICATION = 'booking_system.wsgi.application'
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR / 'db.sqlite3',
}
}
# DATABASES = { # DATABASES = {
# 'default': { # 'default': {
# 'ENGINE': 'django.db.backends.sqlite3', # 'ENGINE': 'django.db.backends.postgresql',
# 'NAME': BASE_DIR / 'db.sqlite3', # 'NAME': os.getenv('POSTGRES_DB'),
# 'USER': os.getenv('POSTGRES_USER'),
# 'PASSWORD': os.getenv('POSTGRES_PASSWORD'),
# 'HOST': os.getenv('POSTGRES_HOST', 'postgres'),
# 'PORT': os.getenv('POSTGRES_PORT', 5432),
# } # }
# } # }
DATABASES = { ENCRYPTION_KEY = os.getenv('ENCRYPTION_KEY')
'default': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': os.getenv('POSTGRES_DB'),
'USER': os.getenv('POSTGRES_USER'),
'PASSWORD': os.getenv('POSTGRES_PASSWORD'),
'HOST': os.getenv('POSTGRES_HOST', 'postgres'),
'PORT': os.getenv('POSTGRES_PORT', 5432),
}
}
@ -113,11 +117,8 @@ SIMPLE_JWT = {
'BLACKLIST_AFTER_ROTATION': True, 'BLACKLIST_AFTER_ROTATION': True,
'SIGNING_KEY': os.getenv('JWT_SECRET', SECRET_KEY), 'SIGNING_KEY': os.getenv('JWT_SECRET', SECRET_KEY),
'AUTH_HEADER_TYPES': ('Bearer',), 'AUTH_HEADER_TYPES': ('Bearer',),
} }
# HIPAA Email Configuration
EMAIL_ENCRYPTION_KEY = os.getenv('EMAIL_ENCRYPTION_KEY')
# Stripe Configuration # Stripe Configuration
STRIPE_PUBLISHABLE_KEY = os.getenv('STRIPE_PUBLISHABLE_KEY') STRIPE_PUBLISHABLE_KEY = os.getenv('STRIPE_PUBLISHABLE_KEY')
@ -125,27 +126,6 @@ STRIPE_SECRET_KEY = os.getenv('STRIPE_SECRET_KEY')
STRIPE_WEBHOOK_SECRET = os.getenv('STRIPE_WEBHOOK_SECRET') STRIPE_WEBHOOK_SECRET = os.getenv('STRIPE_WEBHOOK_SECRET')
# HIPAA Compliance Settings
HIPAA_EMAIL_CONFIG = {
'ENCRYPTION_STANDARD': 'AES-256',
'REQUIRE_SECURE_PORTAL': True,
'AUDIT_RETENTION_DAYS': 365 * 6,
'AUTO_DELETE_UNREAD_DAYS': 30,
'REQUIRE_BAA': True,
}
# Secure Portal URL
SECURE_PORTAL_URL = os.getenv('SECURE_PORTAL_URL', 'https://secure.yourdomain.com')
# Business Associate Agreement Verification
BAA_VERIFICATION = {
'EMAIL_PROVIDER': os.getenv('EMAIL_PROVIDER'),
'BAA_SIGNED': os.getenv('BAA_SIGNED', 'False').lower() == 'true',
'BAA_EXPIRY': os.getenv('BAA_EXPIRY'),
}
# Jitsi Configuration # Jitsi Configuration
JITSI_BASE_URL = os.getenv('JITSI_BASE_URL', 'https://meet.jit.si') JITSI_BASE_URL = os.getenv('JITSI_BASE_URL', 'https://meet.jit.si')

View File

@ -1,8 +1,10 @@
from django.contrib import admin
from django.urls import path, include from django.urls import path, include
from django.contrib import admin
from .views import api_root
urlpatterns = [ urlpatterns = [
path('admin/', admin.site.urls), path('admin/', admin.site.urls),
path('api/auth/', include('users.urls')), path('api/auth/', include('users.urls')),
# path('api/', include('meetings.urls')), path('api/meetings/', include('meetings.urls')),
path('', api_root, name='api-root'),
] ]

367
booking_system/views.py Normal file
View File

@ -0,0 +1,367 @@
from rest_framework.decorators import api_view, permission_classes
from rest_framework.response import Response
from rest_framework.permissions import AllowAny
@api_view(['GET'])
@permission_classes([AllowAny])
def api_root(request, format=None):
base_url = request.build_absolute_uri('/api/')
endpoints = {
'authentication': {
'description': 'User authentication and management endpoints',
'base_path': '/api/auth/',
'endpoints': {
'register': {
'description': 'Register a new user and send verification OTP',
'url': request.build_absolute_uri('/api/auth/register/'),
'methods': ['POST'],
'required_fields': ['email', 'first_name', 'last_name', 'password', 'password2'],
'example_request': {
'email': 'user@example.com',
'first_name': 'John',
'last_name': 'Doe',
'phone_number': '+1234567890',
'password': 'SecurePassword123',
'password2': 'SecurePassword123'
}
},
'verify_otp': {
'description': 'Verify email address using OTP',
'url': request.build_absolute_uri('/api/auth/verify-otp/'),
'methods': ['POST'],
'required_fields': ['email', 'otp'],
'example_request': {
'email': 'user@example.com',
'otp': '123456'
}
},
'login': {
'description': 'Authenticate user and return JWT tokens',
'url': request.build_absolute_uri('/api/auth/login/'),
'methods': ['POST'],
'required_fields': ['email', 'password'],
'example_request': {
'email': 'user@example.com',
'password': 'SecurePassword123'
}
},
'resend_otp': {
'description': 'Resend OTP for email verification or password reset',
'url': request.build_absolute_uri('/api/auth/resend-otp/'),
'methods': ['POST'],
'required_fields': ['email'],
'optional_fields': ['context (registration/password_reset)'],
'example_request': {
'email': 'user@example.com',
'context': 'registration'
}
},
'forgot_password': {
'description': 'Initiate password reset process',
'url': request.build_absolute_uri('/api/auth/forgot-password/'),
'methods': ['POST'],
'required_fields': ['email'],
'example_request': {
'email': 'user@example.com'
}
},
'verify_password_reset_otp': {
'description': 'Verify OTP for password reset',
'url': request.build_absolute_uri('/api/auth/verify-password-reset-otp/'),
'methods': ['POST'],
'required_fields': ['email', 'otp'],
'example_request': {
'email': 'user@example.com',
'otp': '123456'
}
},
'reset_password': {
'description': 'Reset password after OTP verification',
'url': request.build_absolute_uri('/api/auth/reset-password/'),
'methods': ['POST'],
'required_fields': ['email', 'otp', 'new_password', 'confirm_password'],
'example_request': {
'email': 'user@example.com',
'otp': '123456',
'new_password': 'NewSecurePassword123',
'confirm_password': 'NewSecurePassword123'
}
},
'token_refresh': {
'description': 'Refresh access token using refresh token',
'url': request.build_absolute_uri('/api/auth/token/refresh/'),
'methods': ['POST'],
'required_fields': ['refresh'],
'example_request': {
'refresh': 'your_refresh_token_here'
}
}
}
},
"appointments": {
"description": "Appointment request and management system with Jitsi video meetings",
"base_path": "/api/meetings/",
"endpoints": {
"admin_availability": {
"description": "Get or update admin weekly availability (Admin only)",
"url": "http://127.0.0.1:8000/api/meetings/admin/availability/",
"methods": ["GET", "PUT", "PATCH"],
"authentication": "Required (Staff users only)",
"response_fields": {
"available_days": "List of weekday numbers (0-6) when appointments are accepted",
"available_days_display": "Human-readable day names"
},
"example_request": {
"available_days": [0, 1, 2, 3, 4]
}
},
"available_dates": {
"description": "Get available appointment dates for the next 30 days (Public)",
"url": "http://127.0.0.1:8000/api/meetings/appointments/available-dates/",
"methods": ["GET"],
"authentication": "None required",
"response": "List of available dates in YYYY-MM-DD format"
},
"create_appointment": {
"description": "Create a new appointment request (Public)",
"url": "http://127.0.0.1:8000/api/meetings/appointments/create/",
"methods": ["POST"],
"authentication": "None required",
"required_fields": [
"first_name", "last_name", "email",
"preferred_dates", "preferred_time_slots"
],
"optional_fields": ["phone", "reason"],
"example_request": {
"first_name": "John",
"last_name": "Doe",
"email": "john@example.com",
"phone": "+1234567890",
"reason": "Initial consultation for anxiety",
"preferred_dates": ["2024-01-15", "2024-01-16"],
"preferred_time_slots": ["morning", "afternoon"]
},
"validation": "Preferred dates must be within admin available days"
},
"list_appointments": {
"description": "List appointment requests (Admin sees all, users see their own)",
"url": "http://127.0.0.1:8000/api/meetings/appointments/",
"methods": ["GET"],
"authentication": "Required",
"query_parameters": {
"email": "For non-authenticated user lookup (simplified approach)"
},
"response_fields": {
"jitsi_meet_url": "Jitsi meeting URL (only for scheduled appointments)",
"jitsi_room_id": "Jitsi room ID",
"has_jitsi_meeting": "Boolean indicating if meeting is created",
"can_join_meeting": "Boolean indicating if meeting can be joined now",
"meeting_status": "Current meeting status"
}
},
"appointment_detail": {
"description": "Get detailed information about a specific appointment",
"url": "http://127.0.0.1:8000/api/meetings/appointments/<uuid:pk>/",
"methods": ["GET"],
"authentication": "Required",
"url_parameter": "pk (UUID of the appointment)",
"response_includes": "Jitsi meeting information for scheduled appointments"
},
"user_appointments": {
"description": "Get appointments for the authenticated user",
"url": "http://127.0.0.1:8000/api/meetings/user/appointments/",
"methods": ["GET"],
"authentication": "Required",
"response": "List of user's appointment requests with Jitsi meeting details"
},
"schedule_appointment": {
"description": "Schedule an appointment and automatically create Jitsi meeting (Admin only)",
"url": "http://127.0.0.1:8000/api/meetings/appointments/<uuid:pk>/schedule/",
"methods": ["POST"],
"authentication": "Required (Staff users only)",
"required_fields": ["scheduled_datetime"],
"optional_fields": ["scheduled_duration"],
"prerequisites": "Appointment must be in 'pending_review' status",
"example_request": {
"scheduled_datetime": "2024-01-15T10:00:00Z",
"scheduled_duration": 60
},
"side_effects": [
"Updates status to 'scheduled'",
"Automatically generates Jitsi meeting room",
"Creates unique Jitsi room ID and URL",
"Sends confirmation email to user with meeting link",
"Clears rejection reason if any"
],
"response_includes": {
"jitsi_meet_url": "Generated Jitsi meeting URL",
"jitsi_room_id": "Unique Jitsi room ID",
"has_jitsi_meeting": "true"
}
},
"reject_appointment": {
"description": "Reject an appointment request (Admin only)",
"url": "http://127.0.0.1:8000/api/meetings/appointments/<uuid:pk>/reject/",
"methods": ["POST"],
"authentication": "Required (Staff users only)",
"optional_fields": ["rejection_reason"],
"prerequisites": "Appointment must be in 'pending_review' status",
"example_request": {
"rejection_reason": "No availability for preferred dates"
},
"side_effects": [
"Updates status to 'rejected'",
"Clears Jitsi meeting information",
"Sends rejection email to user",
"Clears scheduled datetime if any"
]
},
"jitsi_meeting_info": {
"description": "Get Jitsi meeting information for a scheduled appointment",
"url": "http://127.0.0.1:8000/api/meetings/appointments/<uuid:pk>/jitsi-meeting/",
"methods": ["GET"],
"authentication": "Required",
"prerequisites": "Appointment must be in 'scheduled' status",
"response_fields": {
"meeting_url": "Jitsi meeting URL",
"room_id": "Jitsi room ID",
"scheduled_time": "Formatted scheduled datetime",
"duration": "Meeting duration display",
"can_join": "Boolean indicating if meeting can be joined now",
"meeting_status": "Current meeting status",
"join_instructions": "Instructions for joining the meeting"
}
},
"appointment_stats": {
"description": "Get appointment statistics and analytics (Admin only)",
"url": "http://127.0.0.1:8000/api/meetings/appointments/stats/",
"methods": ["GET"],
"authentication": "Required (Staff users only)",
"response_fields": {
"total_requests": "Total number of appointment requests",
"pending_review": "Number of pending review requests",
"scheduled": "Number of scheduled appointments",
"rejected": "Number of rejected requests",
"completion_rate": "Percentage of requests that were scheduled"
}
}
},
"jitsi_integration": {
"description": "Automatic Jitsi video meeting integration",
"features": [
"Automatic meeting room generation when appointment is scheduled",
"Unique room IDs for each therapy session",
"No setup required for clients - just click and join",
"Meeting availability based on scheduled time",
"Secure, encrypted video sessions"
],
"meeting_lifecycle": {
"pending": "No Jitsi meeting created",
"scheduled": "Jitsi meeting automatically generated with unique URL",
"active": "Meeting can be joined 10 minutes before scheduled time",
"completed": "Meeting ends 15 minutes after scheduled duration"
},
"join_conditions": [
"Appointment must be in 'scheduled' status",
"Current time must be within 10 minutes before to 15 minutes after scheduled end",
"Both client and therapist can join using the same URL"
]
}
}
}
return Response({
'message': 'Therapy Appointment API',
'version': '1.0.0',
'base_url': base_url,
'project_structure': {
'admin': '/admin/ - Django admin interface',
'authentication': '/api/auth/ - User authentication and management',
'appointments': '/api/meetings/ - Appointment booking system'
},
'endpoints': endpoints,
'appointment_workflows': {
'client_booking_flow': [
'1. GET /api/meetings/appointments/available-dates/ - Check available dates',
'2. POST /api/meetings/appointments/create/ - Submit appointment request',
'3. GET /api/meetings/user/appointments/ - Track request status',
'4. Receive email notification when scheduled/rejected'
],
'admin_management_flow': [
'1. PUT /api/meetings/admin/availability/ - Set weekly availability',
'2. GET /api/meetings/appointments/ - Review pending requests',
'3. POST /api/meetings/appointments/{id}/schedule/ - Schedule appointment OR',
'4. POST /api/meetings/appointments/{id}/reject/ - Reject with reason',
'5. GET /api/meetings/appointments/stats/ - Monitor performance'
],
'status_lifecycle': [
'pending_review → scheduled (with datetime)',
'pending_review → rejected (with optional reason)'
]
},
'authentication_flows': {
'registration_flow': [
'1. POST /api/auth/register/ - Register user and send OTP',
'2. POST /api/auth/verify-otp/ - Verify email with OTP',
'3. POST /api/auth/login/ - Login with credentials'
],
'password_reset_flow': [
'1. POST /api/auth/forgot-password/ - Request password reset OTP',
'2. POST /api/auth/verify-password-reset-otp/ - Verify OTP',
'3. POST /api/auth/reset-password/ - Set new password'
]
},
'quick_start': {
'for_users': [
'1. Register: POST /api/auth/register/',
'2. Verify email: POST /api/auth/verify-otp/',
'3. Login: POST /api/auth/login/',
'4. Check availability: GET /api/meetings/appointments/available-dates/',
'5. Book appointment: POST /api/meetings/appointments/create/'
],
'for_admins': [
'1. Login to Django admin: /admin/',
'2. Set availability: PUT /api/meetings/admin/availability/',
'3. Manage appointments: GET /api/meetings/appointments/',
'4. Schedule/Reject: Use specific appointment endpoints'
]
},
'data_specifications': {
'appointment': {
'status_choices': [
'pending_review - Initial state, awaiting admin action',
'scheduled - Approved with specific date/time',
'rejected - Not accepted, with optional reason'
],
'time_slot_choices': [
'morning - 9AM to 12PM',
'afternoon - 1PM to 5PM',
'evening - 6PM to 9PM'
],
'preferred_dates_format': 'YYYY-MM-DD (array of strings)',
'encrypted_fields': [
'first_name', 'last_name', 'email', 'phone',
'reason', 'rejection_reason'
]
},
'availability': {
'day_format': '0=Monday, 1=Tuesday, ..., 6=Sunday',
'example': '[0, 1, 2, 3, 4] for Monday-Friday'
}
},
'authentication_notes': {
'token_usage': 'Include JWT token in Authorization header: Bearer <token>',
'token_refresh': 'Use refresh token to get new access token when expired',
'permissions': {
'public_endpoints': 'No authentication required',
'user_endpoints': 'Valid JWT token required',
'admin_endpoints': 'Staff user with valid JWT token required'
}
}
})

0
meetings/__init__.py Normal file
View File

33
meetings/admin.py Normal file
View File

@ -0,0 +1,33 @@
from django.contrib import admin
from .models import AdminWeeklyAvailability, AppointmentRequest
@admin.register(AdminWeeklyAvailability)
class AdminWeeklyAvailabilityAdmin(admin.ModelAdmin):
list_display = ['available_days_display', 'created_at']
def available_days_display(self, obj):
days_map = dict(AdminWeeklyAvailability.DAYS_OF_WEEK)
return ', '.join([days_map[day] for day in obj.available_days])
available_days_display.short_description = 'Available Days'
@admin.register(AppointmentRequest)
class AppointmentRequestAdmin(admin.ModelAdmin):
list_display = ['full_name', 'email', 'status', 'created_at', 'scheduled_datetime']
list_filter = ['status', 'created_at']
search_fields = ['first_name', 'last_name', 'email']
readonly_fields = ['created_at', 'updated_at']
actions = ['mark_as_scheduled', 'mark_as_rejected']
def mark_as_scheduled(self, request, queryset):
for appointment in queryset:
if appointment.status == 'pending_review':
appointment.status = 'scheduled'
appointment.save()
mark_as_scheduled.short_description = "Mark selected as scheduled"
def mark_as_rejected(self, request, queryset):
for appointment in queryset:
if appointment.status == 'pending_review':
appointment.status = 'rejected'
appointment.save()
mark_as_rejected.short_description = "Mark selected as rejected"

6
meetings/apps.py Normal file
View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class MeetingsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'meetings'

86
meetings/email_service.py Normal file
View File

@ -0,0 +1,86 @@
from django.core.mail import EmailMultiAlternatives
from django.template.loader import render_to_string
from django.conf import settings
class EmailService:
@staticmethod
def send_admin_notification(appointment):
subject = f"New Appointment Request from {appointment.full_name}"
context = {
'appointment': appointment,
'preferred_dates': appointment.get_preferred_dates_display(),
'preferred_times': appointment.get_preferred_time_slots_display(),
'admin_dashboard_url': f"{settings.FRONTEND_URL}/admin/appointments" if hasattr(settings, 'FRONTEND_URL') else '/admin/'
}
html_message = render_to_string('emails/admin_notification.html', context)
admin_email = getattr(settings, 'ADMIN_EMAIL', 'hello@attunehearttherapy.com')
try:
email = EmailMultiAlternatives(
subject=subject,
body="Please view this email in an HTML-compatible client.", # Fallback text
from_email=settings.DEFAULT_FROM_EMAIL,
to=[admin_email],
)
email.attach_alternative(html_message, "text/html")
email.send(fail_silently=False)
return True
except Exception as e:
print(f"Failed to send admin notification: {e}")
return False
@staticmethod
def send_appointment_scheduled(appointment):
subject = "Your Appointment Has Been Scheduled"
context = {
'appointment': appointment,
'scheduled_datetime': appointment.formatted_scheduled_datetime,
'user_dashboard_url': f"{settings.FRONTEND_URL}/dashboard" if hasattr(settings, 'FRONTEND_URL') else '/dashboard/'
}
html_message = render_to_string('emails/appointment_scheduled.html', context)
try:
email = EmailMultiAlternatives(
subject=subject,
body="Please view this email in an HTML-compatible client.", # Fallback text
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 scheduled notification: {e}")
return False
@staticmethod
def send_appointment_rejected(appointment):
subject = "Update on Your Appointment Request"
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/'
}
html_message = render_to_string('emails/appointment_rejected.html', context)
try:
email = EmailMultiAlternatives(
subject=subject,
body="Please view this email in an HTML-compatible client.", # Fallback text
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 rejection notification: {e}")
return False

View File

@ -0,0 +1,52 @@
# Generated by Django 5.2.8 on 2025-11-22 22:06
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')),
('available_days', models.JSONField(default=list, help_text='List of weekdays (0-6) when appointments are accepted')),
('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', meetings.models.EncryptedCharField(max_length=100)),
('last_name', meetings.models.EncryptedCharField(max_length=100)),
('email', meetings.models.EncryptedEmailField()),
('phone', meetings.models.EncryptedCharField(blank=True, max_length=20)),
('reason', meetings.models.EncryptedTextField(blank=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')], default='pending_review', max_length=20)),
('scheduled_datetime', models.DateTimeField(blank=True, null=True)),
('rejection_reason', meetings.models.EncryptedTextField(blank=True)),
('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'],
},
),
]

View File

@ -0,0 +1,41 @@
# Generated by Django 5.2.8 on 2025-11-22 23:31
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('meetings', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='appointmentrequest',
name='jitsi_meet_url',
field=models.URLField(blank=True, help_text='Jitsi Meet URL for the video session'),
),
migrations.AddField(
model_name='appointmentrequest',
name='jitsi_room_id',
field=models.CharField(blank=True, help_text='Jitsi room ID', max_length=100, unique=True),
),
migrations.AddField(
model_name='appointmentrequest',
name='scheduled_duration',
field=models.PositiveIntegerField(default=60, help_text='Duration in minutes'),
),
migrations.AlterField(
model_name='appointmentrequest',
name='status',
field=models.CharField(choices=[('pending_review', 'Pending Review'), ('scheduled', 'Scheduled'), ('rejected', 'Rejected'), ('completed', 'Completed'), ('cancelled', 'Cancelled')], default='pending_review', max_length=20),
),
migrations.AddIndex(
model_name='appointmentrequest',
index=models.Index(fields=['status', 'scheduled_datetime'], name='meetings_ap_status_4e4e26_idx'),
),
migrations.AddIndex(
model_name='appointmentrequest',
index=models.Index(fields=['email', 'created_at'], name='meetings_ap_email_b8ed9d_idx'),
),
]

View File

300
meetings/models.py Normal file
View File

@ -0,0 +1,300 @@
import uuid
from django.db import models
from django.conf import settings
from django.utils import timezone
from cryptography.fernet import Fernet
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
import base64
import os
class EncryptionManager:
def __init__(self):
self.fernet = self._get_fernet()
def _get_fernet_key(self):
key = getattr(settings, 'ENCRYPTION_KEY', None) or os.environ.get('ENCRYPTION_KEY')
if not key:
key = Fernet.generate_key().decode()
key = key.encode()
return key
def _get_fernet(self):
key = self._get_fernet_key()
return Fernet(key)
def encrypt_value(self, value):
if value is None or value == "":
return value
encrypted_value = self.fernet.encrypt(value.encode())
return base64.urlsafe_b64encode(encrypted_value).decode()
def decrypt_value(self, encrypted_value):
if encrypted_value is None or encrypted_value == "":
return encrypted_value
try:
encrypted_bytes = base64.urlsafe_b64decode(encrypted_value.encode())
decrypted_value = self.fernet.decrypt(encrypted_bytes)
return decrypted_value.decode()
except Exception as e:
print(f"Decryption error: {e}")
return encrypted_value
encryption_manager = EncryptionManager()
class EncryptedCharField(models.CharField):
def from_db_value(self, value, expression, connection):
if value is None:
return value
return encryption_manager.decrypt_value(value)
def get_prep_value(self, value):
if value is None:
return value
return encryption_manager.encrypt_value(value)
class EncryptedEmailField(EncryptedCharField):
def __init__(self, *args, **kwargs):
kwargs['max_length'] = 254
super().__init__(*args, **kwargs)
def from_db_value(self, value, expression, connection):
if value is None:
return value
return encryption_manager.decrypt_value(value)
def get_prep_value(self, value):
if value is None:
return value
return encryption_manager.encrypt_value(value)
class EncryptedTextField(models.TextField):
def from_db_value(self, value, expression, connection):
if value is None:
return value
return encryption_manager.decrypt_value(value)
def get_prep_value(self, value):
if value is None:
return value
return encryption_manager.encrypt_value(value)
class AdminWeeklyAvailability(models.Model):
DAYS_OF_WEEK = [
(0, 'Monday'),
(1, 'Tuesday'),
(2, 'Wednesday'),
(3, 'Thursday'),
(4, 'Friday'),
(5, 'Saturday'),
(6, 'Sunday'),
]
available_days = models.JSONField(
default=list,
help_text="List of weekdays (0-6) when appointments are accepted"
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = 'Admin Weekly Availability'
verbose_name_plural = 'Admin Weekly Availability'
def __str__(self):
days = [self.DAYS_OF_WEEK[day][1] for day in self.available_days]
return f"Available: {', '.join(days)}"
class AppointmentRequest(models.Model):
STATUS_CHOICES = [
('pending_review', 'Pending Review'),
('scheduled', 'Scheduled'),
('rejected', 'Rejected'),
('completed', 'Completed'),
('cancelled', 'Cancelled'),
]
TIME_SLOT_CHOICES = [
('morning', 'Morning (9AM - 12PM)'),
('afternoon', 'Afternoon (1PM - 5PM)'),
('evening', 'Evening (6PM - 9PM)'),
]
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
first_name = EncryptedCharField(max_length=100)
last_name = EncryptedCharField(max_length=100)
email = EncryptedEmailField()
phone = EncryptedCharField(max_length=20, blank=True)
reason = EncryptedTextField(blank=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(
max_length=20,
choices=STATUS_CHOICES,
default='pending_review'
)
scheduled_datetime = models.DateTimeField(null=True, blank=True)
scheduled_duration = models.PositiveIntegerField(
default=60,
help_text="Duration in minutes"
)
rejection_reason = EncryptedTextField(blank=True)
jitsi_meet_url = models.URLField(blank=True, help_text="Jitsi Meet URL for the video session")
jitsi_room_id = models.CharField(max_length=100, unique=True, blank=True, help_text="Jitsi room ID")
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ['-created_at']
verbose_name = 'Appointment Request'
verbose_name_plural = 'Appointment Requests'
indexes = [
models.Index(fields=['status', 'scheduled_datetime']),
models.Index(fields=['email', 'created_at']),
]
@property
def full_name(self):
return f"{self.first_name} {self.last_name}"
@property
def formatted_created_at(self):
return self.created_at.strftime("%B %d, %Y at %I:%M %p")
@property
def formatted_scheduled_datetime(self):
if self.scheduled_datetime:
return self.scheduled_datetime.strftime("%B %d, %Y at %I:%M %p")
return None
@property
def has_jitsi_meeting(self):
return bool(self.jitsi_meet_url and self.jitsi_room_id)
@property
def meeting_in_future(self):
if not self.scheduled_datetime:
return False
return self.scheduled_datetime > timezone.now()
@property
def meeting_duration_display(self):
hours = self.scheduled_duration // 60
minutes = self.scheduled_duration % 60
if hours > 0:
return f"{hours}h {minutes}m"
return f"{minutes}m"
def get_preferred_dates_display(self):
try:
dates = [timezone.datetime.strptime(date, '%Y-%m-%d').strftime('%b %d, %Y')
for date in self.preferred_dates]
return ', '.join(dates)
except:
return ', '.join(self.preferred_dates)
def get_preferred_time_slots_display(self):
slot_display = {
'morning': 'Morning',
'afternoon': 'Afternoon',
'evening': 'Evening'
}
return ', '.join([slot_display.get(slot, slot) for slot in self.preferred_time_slots])
def generate_jitsi_room_id(self):
if not self.jitsi_room_id:
self.jitsi_room_id = f"therapy_session_{self.id.hex[:16]}"
return self.jitsi_room_id
def create_jitsi_meeting(self):
if not self.jitsi_room_id:
self.generate_jitsi_room_id()
jitsi_base_url = getattr(settings, 'JITSI_BASE_URL', 'https://meet.jit.si')
self.jitsi_meet_url = f"{jitsi_base_url}/{self.jitsi_room_id}"
return self.jitsi_meet_url
def get_jitsi_join_info(self):
if not self.has_jitsi_meeting:
return None
return {
'meeting_url': self.jitsi_meet_url,
'room_id': self.jitsi_room_id,
'scheduled_time': self.formatted_scheduled_datetime,
'duration': self.meeting_duration_display,
'join_instructions': 'Click the meeting URL to join the video session. No password required.'
}
def schedule_appointment(self, datetime_obj, duration=60, commit=True):
self.status = 'scheduled'
self.scheduled_datetime = datetime_obj
self.scheduled_duration = duration
self.rejection_reason = ''
self.create_jitsi_meeting()
if commit:
self.save()
def reject_appointment(self, reason='', commit=True):
self.status = 'rejected'
self.rejection_reason = reason
self.scheduled_datetime = None
self.jitsi_meet_url = ''
self.jitsi_room_id = ''
if commit:
self.save()
def cancel_appointment(self, reason='', commit=True):
self.status = 'cancelled'
self.rejection_reason = reason
if commit:
self.save()
def complete_appointment(self, commit=True):
self.status = 'completed'
if commit:
self.save()
def can_join_meeting(self):
if not self.scheduled_datetime or not self.has_jitsi_meeting:
return False
if self.status != 'scheduled':
return False
now = timezone.now()
meeting_start = self.scheduled_datetime
meeting_end = meeting_start + timezone.timedelta(minutes=self.scheduled_duration + 15) # 15 min buffer
return meeting_start - timezone.timedelta(minutes=10) <= now <= meeting_end
def get_meeting_status(self):
if not self.scheduled_datetime:
return "Not scheduled"
now = timezone.now()
meeting_start = self.scheduled_datetime
if now < meeting_start - timezone.timedelta(minutes=10):
return "Scheduled"
elif self.can_join_meeting():
return "Ready to join"
elif now > meeting_start + timezone.timedelta(minutes=self.scheduled_duration):
return "Completed"
else:
return "Ended"
def __str__(self):
return f"{self.full_name} - {self.get_status_display()} - {self.created_at.strftime('%Y-%m-%d')}"

98
meetings/serializers.py Normal file
View File

@ -0,0 +1,98 @@
from rest_framework import serializers
from .models import AdminWeeklyAvailability, AppointmentRequest
from django.utils import timezone
from datetime import datetime, timedelta
import json
class AdminWeeklyAvailabilitySerializer(serializers.ModelSerializer):
available_days_display = serializers.SerializerMethodField()
class Meta:
model = AdminWeeklyAvailability
fields = ['id', 'available_days', 'available_days_display', 'created_at', 'updated_at']
def get_available_days_display(self, obj):
days_map = dict(AdminWeeklyAvailability.DAYS_OF_WEEK)
return [days_map[day] for day in obj.available_days]
class AppointmentRequestSerializer(serializers.ModelSerializer):
full_name = serializers.ReadOnlyField()
formatted_created_at = serializers.ReadOnlyField()
formatted_scheduled_datetime = serializers.ReadOnlyField()
preferred_dates_display = serializers.ReadOnlyField()
preferred_time_slots_display = serializers.ReadOnlyField()
has_jitsi_meeting = serializers.ReadOnlyField()
jitsi_meet_url = serializers.ReadOnlyField()
jitsi_room_id = serializers.ReadOnlyField()
can_join_meeting = serializers.ReadOnlyField()
meeting_status = serializers.ReadOnlyField()
class Meta:
model = AppointmentRequest
fields = [
'id', 'first_name', 'last_name', 'email', 'phone', 'reason',
'preferred_dates', 'preferred_time_slots', 'status',
'scheduled_datetime', 'scheduled_duration', 'rejection_reason',
'jitsi_meet_url', 'jitsi_room_id', 'created_at', 'updated_at',
'full_name', 'formatted_created_at', 'formatted_scheduled_datetime',
'preferred_dates_display', 'preferred_time_slots_display',
'has_jitsi_meeting', 'can_join_meeting', 'meeting_status'
]
read_only_fields = [
'id', 'status', 'scheduled_datetime', 'scheduled_duration',
'rejection_reason', 'jitsi_meet_url', 'jitsi_room_id',
'created_at', 'updated_at'
]
class AppointmentRequestCreateSerializer(serializers.ModelSerializer):
class Meta:
model = AppointmentRequest
fields = [
'first_name', 'last_name', 'email', 'phone', 'reason',
'preferred_dates', 'preferred_time_slots'
]
def validate_preferred_dates(self, value):
if not value or len(value) == 0:
raise serializers.ValidationError("At least one preferred date is required.")
today = timezone.now().date()
for date_str in value:
try:
date_obj = datetime.strptime(date_str, '%Y-%m-%d').date()
if date_obj < today:
raise serializers.ValidationError("Preferred dates cannot be in the past.")
except ValueError:
raise serializers.ValidationError(f"Invalid date format: {date_str}. Use YYYY-MM-DD.")
return value
def validate_preferred_time_slots(self, value):
if not value or len(value) == 0:
raise serializers.ValidationError("At least one time slot is required.")
valid_slots = ['morning', 'afternoon', 'evening']
for slot in value:
if slot not in valid_slots:
raise serializers.ValidationError(f"Invalid time slot: {slot}. Must be one of {valid_slots}.")
return value
class AppointmentScheduleSerializer(serializers.Serializer):
scheduled_datetime = serializers.DateTimeField()
scheduled_duration = serializers.IntegerField(default=60, min_value=30, max_value=240)
def validate_scheduled_datetime(self, value):
if value <= timezone.now():
raise serializers.ValidationError("Scheduled datetime must be in the future.")
return value
def validate_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
class AppointmentRejectSerializer(serializers.Serializer):
rejection_reason = serializers.CharField(required=False, allow_blank=True)

273
meetings/tasks.py Normal file
View File

@ -0,0 +1,273 @@
from django.core.mail import send_mail
from django.conf import settings
from django.template.loader import render_to_string
import logging
logger = logging.getLogger(__name__)
def send_booking_notification_email(booking_id):
"""
Send email to admin when a new therapy booking is submitted
"""
try:
from .models import TherapyBooking
booking = TherapyBooking.objects.get(id=booking_id)
subject = f"New Therapy Booking Request - {booking.full_name}"
html_message = render_to_string('emails/booking_notification.html', {
'booking': booking,
})
plain_message = f"""
New Therapy Booking Request Received!
Client: {booking.full_name}
Email: {booking.email}
Phone: {booking.phone}
Appointment Type: {booking.get_appointment_type_display()}
Preferred Date: {booking.preferred_date}
Preferred Time: {booking.preferred_time}
Additional Message:
{booking.additional_message or 'No additional message provided.'}
Please review this booking in the admin dashboard.
"""
# Send to admin email
admin_email = settings.ADMIN_EMAIL or settings.DEFAULT_FROM_EMAIL
send_mail(
subject=subject,
message=plain_message,
from_email=settings.DEFAULT_FROM_EMAIL,
recipient_list=[admin_email],
html_message=html_message,
fail_silently=True, # Don't crash if email fails
)
logger.info(f"Booking notification email sent for booking {booking_id}")
except Exception as e:
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)
logger.info(f"Attempting to send confirmation email for booking {booking_id} to {booking.email}")
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}",
})
# Get appointment duration
duration = get_appointment_duration(booking.appointment_type)
# Format datetime for plain text
formatted_datetime = booking.confirmed_datetime.strftime('%A, %B %d, %Y at %I:%M %p')
# Build plain text message dynamically
plain_message_parts = [
f"APPOINTMENT CONFIRMED - Attune Heart Therapy",
f"",
f"Dear {booking.full_name},",
f"",
f"We're delighted to confirm your {booking.get_appointment_type_display()} appointment.",
f"",
f"APPOINTMENT DETAILS:",
f"- Type: {booking.get_appointment_type_display()}",
f"- Date & Time: {formatted_datetime}",
f"- Duration: {duration}",
f"- Therapist: {booking.assigned_therapist.get_full_name() if booking.assigned_therapist else 'To be assigned'}",
f"- Payment Status: {booking.get_payment_status_display()}",
f"",
f"JOIN YOUR SESSION:",
f"Video Meeting Link: {booking.jitsi_meet_url}",
f"",
f"Please join 5-10 minutes before your scheduled time to test your audio and video.",
f"",
f"PREPARATION TIPS:",
f"• Test your camera, microphone, and internet connection",
f"• Find a quiet, private space",
f"• Use Chrome, Firefox, or Safari for best experience",
f"• Have a glass of water nearby",
f""
]
# Add payment information if not paid
if booking.payment_status != 'paid':
plain_message_parts.extend([
f"PAYMENT INFORMATION:",
f"Your session fee of ${booking.amount} is pending. Please complete your payment before the session.",
f""
])
plain_message_parts.extend([
f"NEED TO RESCHEDULE?",
f"Please contact us at least 24 hours in advance at (954) 807-3027.",
f"",
f"We look forward to supporting you on your healing journey!",
f"",
f"Warm regards,",
f"The Attune Heart Therapy Team",
f"",
f"Contact Information:",
f"📞 (954) 807-3027",
f"✉️ hello@attunehearttherapy.com",
f"🌐 attunehearttherapy.com",
f"",
f"Confirmation ID: {booking.id}"
])
plain_message = "\n".join(plain_message_parts)
result = send_mail(
subject=subject,
message=plain_message,
from_email=settings.DEFAULT_FROM_EMAIL,
recipient_list=[booking.email],
html_message=html_message,
fail_silently=False,
)
logger.info(f"✅ Booking confirmation email sent successfully to {booking.email}. Sendmail result: {result}")
return True
except Exception as e:
logger.error(f"❌ Failed to send booking confirmation email: {str(e)}", exc_info=True)
return False
def get_appointment_duration(appointment_type):
"""Helper function to get appointment duration"""
durations = {
'initial-consultation': '90 minutes',
'individual-therapy': '60 minutes',
'family-therapy': '90 minutes',
'couples-therapy': '75 minutes',
'group-therapy': '90 minutes',
'follow-up': '45 minutes',
}
return durations.get(appointment_type, '60 minutes')
def get_appointment_duration(appointment_type):
"""Helper function to get appointment duration"""
durations = {
'initial-consultation': '90 minutes',
'individual-therapy': '60 minutes',
'family-therapy': '90 minutes',
'couples-therapy': '75 minutes',
'group-therapy': '90 minutes',
'follow-up': '45 minutes',
}
return durations.get(appointment_type, '60 minutes')
def send_payment_confirmation_email(booking_id):
try:
from .models import TherapyBooking
booking = TherapyBooking.objects.get(id=booking_id)
subject = f"💳 Payment Confirmed - {booking.get_appointment_type_display()}"
html_message = render_to_string('emails/payment_confirmed.html', {
'booking': booking,
})
duration = get_appointment_duration(booking.appointment_type)
payment_id = booking.stripe_payment_intent_id or str(booking.id)
plain_message = f"""
PAYMENT CONFIRMED - Attune Heart Therapy
Dear {booking.full_name},
Thank you for your payment! Your {booking.get_appointment_type_display()} appointment is now fully confirmed.
PAYMENT DETAILS:
- Amount Paid: ${booking.amount}
- Payment Date: {booking.paid_at.strftime('%B %d, %Y at %I:%M %p')}
- Payment ID: {payment_id}
- Appointment: {booking.get_appointment_type_display()}
SESSION DETAILS:
- Date & Time: {booking.confirmed_datetime.strftime('%A, %B %d, %Y at %I:%M %p')}
- Duration: {duration}
- Therapist: {booking.assigned_therapist.get_full_name() if booking.assigned_therapist else 'To be assigned'}
- Video Meeting: {booking.jitsi_meet_url}
Please join 5-10 minutes before your scheduled time to test your audio and video.
This email serves as your receipt for tax purposes.
If you have any questions about your payment or appointment, please contact us at (954) 807-3027.
Thank you for trusting us with your care!
Warm regards,
The Attune Heart Therapy Team
Contact Information:
📞 (954) 807-3027
hello@attunehearttherapy.com
🌐 attunehearttherapy.com
Payment ID: {payment_id}
Processed: {booking.paid_at.strftime('%Y-%m-%d %H:%M')}
"""
send_mail(
subject=subject,
message=plain_message,
from_email=settings.DEFAULT_FROM_EMAIL,
recipient_list=[booking.email],
html_message=html_message,
fail_silently=False,
)
logger.info(f"✅ Payment confirmation email sent to {booking.email}")
return True
except Exception as e:
logger.error(f"❌ Failed to send payment confirmation email: {str(e)}")
return False
def send_payment_failure_email(booking_id):
"""
Send payment failure email
"""
try:
from .models import TherapyBooking
booking = TherapyBooking.objects.get(id=booking_id)
subject = f"Payment Issue - {booking.get_appointment_type_display()}"
plain_message = f"""
Payment Issue
Dear {booking.full_name},
We encountered an issue processing your payment for the {booking.get_appointment_type_display()} appointment.
Please try again or contact us at (954) 807-3027 to complete your payment.
Best regards,
Attune Heart Therapy Team
"""
send_mail(
subject=subject,
message=plain_message,
from_email=settings.DEFAULT_FROM_EMAIL,
recipient_list=[booking.email],
fail_silently=True,
)
except Exception as e:
logger.error(f"Failed to send payment failure email: {str(e)}")

3
meetings/tests.py Normal file
View File

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

28
meetings/urls.py Normal file
View File

@ -0,0 +1,28 @@
from django.urls import path
from .views import (
AdminAvailabilityView,
AppointmentRequestListView,
AppointmentRequestCreateView,
AppointmentRequestDetailView,
schedule_appointment,
reject_appointment,
available_dates,
user_appointments,
appointment_stats
)
urlpatterns = [
path('admin/availability/', AdminAvailabilityView.as_view(), name='admin-availability'),
path('appointments/', AppointmentRequestListView.as_view(), name='appointment-list'),
path('appointments/create/', AppointmentRequestCreateView.as_view(), name='appointment-create'),
path('appointments/<uuid:pk>/', AppointmentRequestDetailView.as_view(), name='appointment-detail'),
path('appointments/<uuid:pk>/schedule/', schedule_appointment, name='appointment-schedule'),
path('appointments/<uuid:pk>/reject/', reject_appointment, name='appointment-reject'),
path('appointments/available-dates/', available_dates, name='available-dates'),
path('user/appointments/', user_appointments, name='user-appointments'),
path('appointments/stats/', appointment_stats, name='appointment-stats'),
]

168
meetings/views.py Normal file
View File

@ -0,0 +1,168 @@
from rest_framework import generics, status
from rest_framework.decorators import api_view, permission_classes
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated, AllowAny
from django.utils import timezone
from datetime import datetime, timedelta
from .models import AdminWeeklyAvailability, AppointmentRequest
from .serializers import (
AdminWeeklyAvailabilitySerializer,
AppointmentRequestSerializer,
AppointmentRequestCreateSerializer,
AppointmentScheduleSerializer,
AppointmentRejectSerializer
)
from .email_service import EmailService
class AdminAvailabilityView(generics.RetrieveUpdateAPIView):
permission_classes = [IsAuthenticated]
serializer_class = AdminWeeklyAvailabilitySerializer
def get_object(self):
obj, created = AdminWeeklyAvailability.objects.get_or_create(
defaults={'available_days': []}
)
return obj
class AppointmentRequestListView(generics.ListAPIView):
serializer_class = AppointmentRequestSerializer
permission_classes = [IsAuthenticated]
def get_queryset(self):
queryset = AppointmentRequest.objects.all()
if self.request.user.is_staff:
return queryset
return queryset.filter(email=self.request.user.email)
class AppointmentRequestCreateView(generics.CreateAPIView):
permission_classes = [AllowAny]
queryset = AppointmentRequest.objects.all()
serializer_class = AppointmentRequestCreateSerializer
def perform_create(self, serializer):
availability = AdminWeeklyAvailability.objects.first()
if availability:
available_days = availability.available_days
preferred_dates = serializer.validated_data['preferred_dates']
for date_str in preferred_dates:
date_obj = datetime.strptime(date_str, '%Y-%m-%d').date()
if date_obj.weekday() not in available_days:
from rest_framework.exceptions import ValidationError
raise ValidationError(f'Date {date_str} is not available for appointments.')
appointment = serializer.save()
EmailService.send_admin_notification(appointment)
class AppointmentRequestDetailView(generics.RetrieveAPIView):
permission_classes = [IsAuthenticated]
queryset = AppointmentRequest.objects.all()
serializer_class = AppointmentRequestSerializer
lookup_field = 'pk'
@api_view(['POST'])
@permission_classes([IsAuthenticated])
def schedule_appointment(request, pk):
try:
appointment = AppointmentRequest.objects.get(pk=pk)
except AppointmentRequest.DoesNotExist:
return Response({'error': 'Appointment not found'}, status=status.HTTP_404_NOT_FOUND)
if appointment.status != 'pending_review':
return Response(
{'error': 'Only pending review appointments can be scheduled.'},
status=status.HTTP_400_BAD_REQUEST
)
serializer = AppointmentScheduleSerializer(data=request.data)
if serializer.is_valid():
appointment.schedule_appointment(
datetime_obj=serializer.validated_data['scheduled_datetime'],
duration=serializer.validated_data['scheduled_duration']
)
EmailService.send_appointment_scheduled(appointment)
response_serializer = AppointmentRequestSerializer(appointment)
return Response({
**response_serializer.data,
'message': 'Appointment scheduled successfully. Jitsi meeting created.',
'jitsi_meeting_created': appointment.has_jitsi_meeting
})
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@api_view(['POST'])
@permission_classes([IsAuthenticated])
def reject_appointment(request, pk):
try:
appointment = AppointmentRequest.objects.get(pk=pk)
except AppointmentRequest.DoesNotExist:
return Response({'error': 'Appointment not found'}, status=status.HTTP_404_NOT_FOUND)
if appointment.status != 'pending_review':
return Response(
{'error': 'Only pending appointments can be rejected'},
status=status.HTTP_400_BAD_REQUEST
)
serializer = AppointmentRejectSerializer(data=request.data)
if serializer.is_valid():
appointment.reject_appointment(serializer.validated_data.get('rejection_reason', ''))
EmailService.send_appointment_rejected(appointment)
return Response(AppointmentRequestSerializer(appointment).data)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@api_view(['GET'])
@permission_classes([AllowAny])
def available_dates(request):
availability = AdminWeeklyAvailability.objects.first()
if not availability:
return Response([])
available_days = availability.available_days
today = timezone.now().date()
available_dates = []
for i in range(1, 31):
date = today + timedelta(days=i)
if date.weekday() in available_days:
available_dates.append(date.strftime('%Y-%m-%d'))
return Response(available_dates)
@api_view(['GET'])
@permission_classes([IsAuthenticated])
def user_appointments(request):
appointments = AppointmentRequest.objects.filter(
email=request.user.email
).order_by('-created_at')
serializer = AppointmentRequestSerializer(appointments, many=True)
return Response(serializer.data)
@api_view(['GET'])
@permission_classes([IsAuthenticated])
def appointment_stats(request):
if not request.user.is_staff:
return Response(
{'error': 'Unauthorized'},
status=status.HTTP_403_FORBIDDEN
)
total = AppointmentRequest.objects.count()
pending = AppointmentRequest.objects.filter(status='pending_review').count()
scheduled = AppointmentRequest.objects.filter(status='scheduled').count()
rejected = AppointmentRequest.objects.filter(status='rejected').count()
return Response({
'total_requests': total,
'pending_review': pending,
'scheduled': scheduled,
'rejected': rejected,
'completion_rate': round((scheduled / total * 100), 2) if total > 0 else 0
})

View File

@ -1,100 +0,0 @@
{% extends "emails/base.html" %}
{% block title %}New Therapy Booking Request - Action Required{% endblock %}
{% block content %}
<div class="email-header" style="background: linear-gradient(135deg, #dc2626, #ea580c);">
<h1>📋 NEW THERAPY BOOKING REQUEST</h1>
</div>
<div class="email-body">
<div class="urgent-badge">⏰ ACTION REQUIRED - Please respond within 24 hours</div>
<div class="section">
<h2 class="section-title">Patient Information</h2>
<div class="info-card">
<div class="info-item">
<span class="info-label">Full Name:</span>
<span class="info-value">{{ booking.full_name }}</span>
</div>
<div class="info-item">
<span class="info-label">Email:</span>
<span class="info-value">{{ booking.email }}</span>
</div>
<div class="info-item">
<span class="info-label">Phone:</span>
<span class="info-value">{{ booking.phone }}</span>
</div>
<div class="info-item">
<span class="info-label">Submitted:</span>
<span class="info-value">{{ booking.created_at|date:"F d, Y" }} at {{ booking.created_at|time:"g:i A" }}</span>
</div>
</div>
</div>
<div class="section">
<h2 class="section-title">Appointment Details</h2>
<div class="info-card">
<div class="info-item">
<span class="info-label">Appointment Type:</span>
<span class="info-value">{{ booking.get_appointment_type_display }}</span>
</div>
<div class="info-item">
<span class="info-label">Preferred Date:</span>
<span class="info-value">{{ booking.preferred_date|date:"l, F d, Y" }}</span>
</div>
<div class="info-item">
<span class="info-label">Preferred Time:</span>
<span class="info-value">{{ booking.preferred_time }}</span>
</div>
<div class="info-item">
<span class="info-label">Session Fee:</span>
<span class="info-value">${{ booking.amount }}</span>
</div>
</div>
</div>
{% if booking.additional_message %}
<div class="section">
<h2 class="section-title">Patient's Message</h2>
<div class="info-card">
<div style="background: white; padding: 16px; border-radius: 6px; border-left: 4px solid #10b981;">
{{ booking.additional_message }}
</div>
</div>
</div>
{% endif %}
<div class="section">
<h2 class="section-title">Required Actions</h2>
<div class="steps">
<div class="step">
<strong>Review Patient Information</strong><br>
Assess clinical appropriateness and availability.
</div>
<div class="step">
<strong>Contact Patient</strong><br>
Reach out within 24 hours to confirm appointment details.
</div>
<div class="step">
<strong>Confirm Booking</strong><br>
Update booking status and send confirmation email.
</div>
</div>
</div>
<div style="text-align: center; margin: 30px 0;">
<a href="https://attunehearttherapy.com/admin" class="button" style="background: linear-gradient(135deg, #dc2626, #ea580c);">
📊 Manage This Booking
</a>
</div>
</div>
<div class="footer">
<p><strong>Attune Heart Therapy - Admin Portal</strong></p>
<div class="contact-info">
Booking ID: {{ booking.id }}<br>
Received: {{ booking.created_at|date:"Y-m-d H:i" }}
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,304 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>New Appointment Request - Action Required</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;
}
.email-body {
padding: 40px 30px;
}
.urgent-badge {
background: linear-gradient(135deg, #dc2626, #ea580c);
color: white;
padding: 12px 20px;
border-radius: 8px;
text-align: center;
font-weight: 600;
margin-bottom: 30px;
font-size: 16px;
}
.section {
margin-bottom: 30px;
}
.section-title {
font-size: 18px;
font-weight: 600;
color: #1f2937;
margin-bottom: 16px;
border-bottom: 2px solid #f1f5f9;
padding-bottom: 8px;
}
.info-card {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 24px;
}
.info-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.info-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.info-label {
font-weight: 500;
color: #64748b;
font-size: 14px;
}
.info-value {
font-weight: 600;
color: #1e293b;
font-size: 15px;
}
.message-container {
background: white;
padding: 20px;
border-radius: 8px;
border-left: 4px solid #10b981;
font-style: italic;
color: #475569;
line-height: 1.6;
}
.preferences {
background: #fffaf0;
border-left: 4px solid #ed8936;
padding: 20px;
border-radius: 0 8px 8px 0;
margin: 20px 0;
}
.pref-title {
font-weight: 600;
color: #744210;
margin-bottom: 10px;
font-size: 16px;
}
.action-section {
text-align: center;
margin: 35px 0 25px;
}
.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);
}
.alert-note {
background: #fff5f5;
border: 1px solid #fed7d7;
border-radius: 8px;
padding: 15px;
margin-top: 20px;
font-size: 14px;
color: #c53030;
text-align: center;
}
.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;
}
.info-grid {
grid-template-columns: 1fr;
gap: 12px;
}
.urgent-badge {
font-size: 14px;
padding: 10px 16px;
}
}
</style>
</head>
<body>
<div class="email-container">
<!-- Header -->
<div class="email-header">
<h1>New Appointment Request</h1>
<p>A client has requested to schedule a therapy session</p>
</div>
<!-- Body -->
<div class="email-body">
<div class="urgent-badge">
ACTION REQUIRED - Please respond within 24 hours
</div>
<!-- Client Information Section -->
<div class="section">
<h2 class="section-title">Client Information</h2>
<div class="info-card">
<div class="info-grid">
<div class="info-item">
<span class="info-label">Full Name</span>
<span class="info-value">{{ appointment.full_name }}</span>
</div>
<div class="info-item">
<span class="info-label">Email Address</span>
<span class="info-value">{{ appointment.email }}</span>
</div>
<div class="info-item">
<span class="info-label">Phone Number</span>
<span class="info-value"
>{{ appointment.phone|default:"Not provided" }}</span
>
</div>
<div class="info-item">
<span class="info-label">Request Date</span>
<span class="info-value"
>{{ appointment.formatted_created_at }}</span
>
</div>
</div>
</div>
</div>
<!-- Client Message Section -->
{% if appointment.reason %}
<div class="section">
<h2 class="section-title">Client's Message</h2>
<div class="message-container">"{{ appointment.reason }}"</div>
</div>
{% endif %}
<!-- Preferences Section -->
<div class="preferences">
<div class="pref-title">Preferred Availability</div>
<div style="margin-bottom: 10px">
<strong>Dates:</strong> {{ preferred_dates }}
</div>
<div><strong>Time Slots:</strong> {{ preferred_times }}</div>
</div>
<!-- Action Button -->
<div class="action-section">
<a href="{{ admin_dashboard_url }}" class="button">
Review Appointment Request
</a>
</div>
<!-- Alert Note -->
<div class="alert-note">
Please respond to this request within 24 hours to ensure the best
client experience.
</div>
</div>
<!-- Footer -->
<div class="email-footer">
<div class="company-name">{{ settings.SITE_NAME|default:"Attune Heart Therapy" }}</div>
<p class="support-info">
Need help? Contact our support team at
<a
href="mailto:hello@attunehearttherapy.com"
style="color: #fff; text-decoration: none"
>hello@attunehearttherapy.com</a
>
</p>
<p class="copyright">
© {% now "Y" %} {{ settings.SITE_NAME|default:"Attune Heart Therapy" }}.
All rights reserved.
</p>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,309 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Appointment Request Update</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;
}
.email-body {
padding: 40px 30px;
}
.greeting {
font-size: 18px;
font-weight: 600;
color: #1f2937;
margin-bottom: 25px;
line-height: 1.8;
}
.status-card {
background: #fff5f5;
border: 1px solid #fed7d7;
border-radius: 12px;
padding: 30px;
text-align: center;
margin: 25px 0;
}
.status-icon {
font-size: 48px;
margin-bottom: 15px;
}
.status-title {
font-size: 22px;
color: #c53030;
margin-bottom: 15px;
font-weight: 600;
}
.reason-box {
background: #f7fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 20px;
margin: 20px 0;
}
.reason-title {
font-weight: 600;
color: #4a5568;
margin-bottom: 10px;
font-size: 16px;
}
.reason-message {
color: #4a5568;
font-style: italic;
line-height: 1.6;
margin-top: 8px;
}
.suggestions {
background: #f0fff4;
border: 1px solid #9ae6b4;
border-radius: 12px;
padding: 25px;
margin: 25px 0;
}
.suggestions-title {
font-weight: 600;
color: #22543d;
margin-bottom: 15px;
font-size: 18px;
}
.suggestions-list {
list-style: none;
}
.suggestions-list li {
margin-bottom: 12px;
padding-left: 25px;
position: relative;
color: #475569;
}
.suggestions-list li:before {
content: "💡";
position: absolute;
left: 0;
}
.next-steps {
background: #ebf8ff;
border: 1px solid #90cdf4;
border-radius: 12px;
padding: 25px;
margin: 25px 0;
}
.steps-title {
font-weight: 600;
color: #2c5282;
margin-bottom: 15px;
font-size: 18px;
}
.contact-box {
background: #edf2f7;
border-radius: 8px;
padding: 20px;
margin: 20px 0;
text-align: center;
color: #475569;
}
.action-section {
text-align: center;
margin: 35px 0 25px;
}
.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);
}
.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;
}
.status-card,
.suggestions,
.next-steps {
padding: 20px;
}
.status-icon {
font-size: 36px;
}
.status-title {
font-size: 20px;
}
}
</style>
</head>
<body>
<div class="email-container">
<!-- Header -->
<div class="email-header">
<h1>Appointment Request Update</h1>
<p>Regarding your recent booking request</p>
</div>
<!-- Body -->
<div class="email-body">
<div class="greeting">
Hello <strong>{{ appointment.full_name }}</strong>,<br />
Thank you for your interest in scheduling an appointment. We've
reviewed your request and need to provide you with an update.
</div>
<!-- Status Card -->
<div class="status-card">
<div class="status-title">Request Not Accepted</div>
<p>
We're unable to accommodate your appointment request at this time.
</p>
</div>
<!-- Reason (if provided) -->
{% if rejection_reason %}
<div class="reason-box">
<div class="reason-title">Message from the therapist:</div>
<div class="reason-message">"{{ rejection_reason }}"</div>
</div>
{% endif %}
<!-- Suggestions -->
<div class="suggestions">
<div class="suggestions-title">Alternative Options</div>
<ul class="suggestions-list">
<li>
Submit a new request with different preferred dates or times
</li>
<li>Consider our group therapy sessions with more availability</li>
<li>Explore our self-guided resources and workshops</li>
<li>Join our waitlist for last-minute availability</li>
</ul>
</div>
<!-- Next Steps -->
<div class="next-steps">
<div class="steps-title">What You Can Do Next</div>
<p style="color: #475569; margin-bottom: 15px">
We value your interest in our services and want to help you find the
right fit:
</p>
<ul class="suggestions-list">
<li>Submit a new appointment request with adjusted preferences</li>
<li>Contact us directly to discuss alternative options</li>
<li>Check back next month for updated availability</li>
</ul>
</div>
</div>
<!-- Footer -->
<div class="email-footer">
<div class="company-name">{{ settings.SITE_NAME|default:"Attune Heart Therapy" }}</div>
<p class="support-info">
Need help? Contact our support team at
<a
href="mailto:hello@attunehearttherapy.com"
style="color: #fff; text-decoration: none"
>hello@attunehearttherapy.com</a
>
</p>
<p class="copyright">
© {% now "Y" %} {{ settings.SITE_NAME|default:"Attune Heart Therapy" }}. All rights reserved.
</p>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,288 @@
<!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);
}
.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">
<!-- Header -->
<div class="email-header">
<h1>Appointment Confirmed!</h1>
<p>Your therapy session has been scheduled</p>
</div>
<!-- Body -->
<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>
<!-- Confirmation Card -->
<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>
</div>
<!-- Footer -->
<div class="email-footer">
<div class="company-name">{{ settings.SITE_NAME|default:"Attune Heart Therapy" }}</div>
<p class="support-info">
Need help? Contact our support team at
<a
href="mailto:{{ support_email }}"
style="color: #fff; text-decoration: none"
>hello@attunehearttherapy.com</a
>
</p>
<p class="copyright">
© {% now "Y" %} {{ settings.SITE_NAME|default:"Attune Heart Therapy" }}. All rights reserved.
</p>
</div>
</div>
</body>
</html>

View File

@ -1,145 +0,0 @@
{% extends "emails/base.html" %}
{% block title %}Appointment Confirmed - Attune Heart Therapy{% endblock %}
{% block content %}
<div class="email-header">
<h1>✅ Your Appointment is Confirmed!</h1>
</div>
<div class="email-body">
<div class="greeting">
Dear <strong>{{ booking.full_name }}</strong>,
</div>
<div class="section">
<p>We're delighted to confirm your <strong>{{ booking.get_appointment_type_display }}</strong> appointment. Your healing journey begins now, and we're honored to walk this path with you.</p>
</div>
<div class="section">
<h2 class="section-title">Appointment Details</h2>
<div class="info-card">
<div class="info-item">
<span class="info-label">Appointment Type:</span>
<span class="info-value">{{ booking.get_appointment_type_display }}</span>
</div>
<div class="info-item">
<span class="info-label">Date & Time:</span>
<span class="info-value">{{ booking.confirmed_datetime|date:"l, F d, Y" }} at {{ booking.confirmed_datetime|time:"g:i A" }}</span>
</div>
<div class="info-item">
<span class="info-label">Duration:</span>
<span class="info-value">
{% if booking.appointment_type == 'initial-consultation' %}90 minutes
{% elif booking.appointment_type == 'individual-therapy' %}60 minutes
{% elif booking.appointment_type == 'family-therapy' %}90 minutes
{% elif booking.appointment_type == 'couples-therapy' %}75 minutes
{% elif booking.appointment_type == 'group-therapy' %}90 minutes
{% elif booking.appointment_type == 'follow-up' %}45 minutes
{% else %}60 minutes{% endif %}
</span>
</div>
<div class="info-item">
<span class="info-label">Therapist:</span>
<span class="info-value">{{ booking.assigned_therapist.get_full_name }}</span>
</div>
{% if booking.payment_status == 'paid' %}
<div class="info-item">
<span class="info-label">Payment Status:</span>
<span class="info-value" style="color: #10b981; font-weight: 600;">✅ Paid</span>
</div>
{% endif %}
</div>
</div>
<div class="section">
<h2 class="section-title">Join Your Session</h2>
<div class="info-card" style="background: linear-gradient(135deg, #f0f9ff, #e0f2fe); border-left-color: #0ea5e9;">
<div style="text-align: center; padding: 10px 0;">
<div style="font-size: 16px; font-weight: 600; color: #0369a1; margin-bottom: 15px;">
📅 Secure Video Session
</div>
<a href="{{ booking.jitsi_meet_url }}" class="button" style="font-size: 16px; padding: 16px 40px;">
🎥 Join Video Session
</a>
<div style="margin-top: 15px; font-size: 14px; color: #64748b;">
Or copy this link:<br>
<span style="word-break: break-all; color: #0ea5e9;">{{ booking.jitsi_meet_url }}</span>
</div>
</div>
</div>
</div>
<div class="section">
<h2 class="section-title">Preparing for Your Session</h2>
<div class="steps">
<div class="step">
<strong>Test Your Technology</strong><br>
Please test your camera, microphone, and internet connection before the session.
</div>
<div class="step">
<strong>Find a Quiet Space</strong><br>
Choose a private, comfortable location where you won't be interrupted.
</div>
<div class="step">
<strong>Join Early</strong><br>
Please join 5-10 minutes before your scheduled time to ensure everything is working.
</div>
<div class="step">
<strong>Browser Recommendation</strong><br>
Use Chrome, Firefox, or Safari for the best video experience.
</div>
</div>
</div>
{% if booking.payment_status != 'paid' %}
<div class="section">
<h2 class="section-title">Payment Information</h2>
<div class="info-card" style="background: #fffbeb; border-left-color: #f59e0b;">
<div style="text-align: center;">
<div style="color: #d97706; font-weight: 600; margin-bottom: 10px;">
💳 Payment Required
</div>
<p>Your session fee of <strong>${{ booking.amount }}</strong> is pending. Please complete your payment before the session.</p>
<a href="https://attunehearttherapy.com/payment/{{ booking.id }}" class="button" style="background: linear-gradient(135deg, #f59e0b, #d97706);">
Complete Payment
</a>
</div>
</div>
</div>
{% endif %}
<div class="section">
<h2 class="section-title">Need to Reschedule?</h2>
<p>If you need to reschedule or cancel your appointment, please contact us at least 24 hours in advance:</p>
<div style="text-align: center; margin: 20px 0;">
<a href="tel:+19548073027" class="button" style="background: linear-gradient(135deg, #64748b, #475569);">
📞 Call (954) 807-3027
</a>
</div>
</div>
<div class="section">
<div style="background: #f0fdf4; padding: 20px; border-radius: 8px; border-left: 4px solid #10b981;">
<p style="color: #065f46; font-style: italic; margin: 0;">
"The privilege of a lifetime is to become who you truly are."<br>
<span style="font-size: 14px;">- Carl Jung</span>
</p>
</div>
</div>
</div>
<div class="footer">
<p><strong>Attune Heart Therapy</strong></p>
<p>Compassionate Care for Your Healing Journey</p>
<div class="contact-info">
📞 (954) 807-3027<br>
✉️ hello@attunehearttherapy.com<br>
🌐 attunehearttherapy.com
</div>
<p style="font-size: 12px; color: #9ca3af; margin-top: 15px;">
Confirmation ID: {{ booking.id }}<br>
Sent: {{ booking.confirmed_datetime|date:"Y-m-d H:i" }}
</p>
</div>
{% endblock %}

View File

@ -1,86 +0,0 @@
{% extends "emails/base.html" %}
{% block title %}Booking Request Received - Attune Heart Therapy{% endblock %}
{% block content %}
<div class="email-header">
<h1>🎉 Thank You for Your Booking Request!</h1>
</div>
<div class="email-body">
<div class="greeting">
Dear <strong>{{ booking.full_name }}</strong>,
</div>
<div class="section">
<p>We have received your request for a <strong>{{ booking.get_appointment_type_display }}</strong> appointment and we're excited to support you on your healing journey.</p>
</div>
<div class="section">
<h2 class="section-title">Your Request Details</h2>
<div class="info-card">
<div class="info-item">
<span class="info-label">Appointment Type:</span>
<span class="info-value">{{ booking.get_appointment_type_display }}</span>
</div>
<div class="info-item">
<span class="info-label">Preferred Date:</span>
<span class="info-value">{{ booking.preferred_date|date:"l, F d, Y" }}</span>
</div>
<div class="info-item">
<span class="info-label">Preferred Time:</span>
<span class="info-value">{{ booking.preferred_time }}</span>
</div>
{% if booking.additional_message %}
<div class="info-item">
<span class="info-label">Your Message:</span>
<span class="info-value">{{ booking.additional_message }}</span>
</div>
{% endif %}
</div>
</div>
<div class="section">
<h2 class="section-title">What Happens Next?</h2>
<div class="steps">
<div class="step">
<strong>Review Process</strong><br>
Our clinical team will review your request to ensure we're the right fit for your needs.
</div>
<div class="step">
<strong>Confirmation</strong><br>
We'll contact you within 24 hours to confirm your appointment details.
</div>
<div class="step">
<strong>Session Preparation</strong><br>
You'll receive a confirmed date, time, and secure video meeting link.
</div>
</div>
</div>
<div class="section">
<h2 class="section-title">Need Immediate Assistance?</h2>
<p>If you have any questions or need to modify your request, please don't hesitate to contact us:</p>
<div style="text-align: center; margin: 25px 0;">
<a href="tel:+19548073027" class="button">📞 Call Us: (954) 807-3027</a>
</div>
</div>
<div class="section">
<p style="color: #6b7280; font-style: italic;">
"The journey of a thousand miles begins with a single step."<br>
- Lao Tzu
</p>
</div>
</div>
<div class="footer">
<p><strong>Attune Heart Therapy</strong></p>
<p>Healing Hearts, Transforming Lives</p>
<div class="contact-info">
📞 (954) 807-3027<br>
✉️ hello@attunehearttherapy.com<br>
🌐 attunehearttherapy.com
</div>
</div>
{% endblock %}

View File

@ -1,112 +0,0 @@
{% extends "emails/base.html" %}
{% block title %}Payment Confirmed - Attune Heart Therapy{% endblock %}
{% block content %}
<div class="email-header" style="background: linear-gradient(135deg, #10b981, #059669);">
<h1>💳 Payment Confirmed!</h1>
</div>
<div class="email-body">
<div class="greeting">
Dear <strong>{{ booking.full_name }}</strong>,
</div>
<div class="section">
<p>Thank you for your payment! Your <strong>{{ booking.get_appointment_type_display }}</strong> appointment is now fully confirmed and we're looking forward to our session.</p>
</div>
<div class="section">
<h2 class="section-title">Payment Details</h2>
<div class="info-card" style="background: linear-gradient(135deg, #f0fdf4, #dcfce7); border-left-color: #10b981;">
<div class="info-item">
<span class="info-label">Amount Paid:</span>
<span class="info-value" style="color: #059669; font-size: 18px; font-weight: 700;">${{ booking.amount }}</span>
</div>
<div class="info-item">
<span class="info-label">Payment Date:</span>
<span class="info-value">{{ booking.paid_at|date:"F d, Y" }} at {{ booking.paid_at|time:"g:i A" }}</span>
</div>
<div class="info-item">
<span class="info-label">Appointment:</span>
<span class="info-value">{{ booking.get_appointment_type_display }}</span>
</div>
<div class="info-item">
<span class="info-label">Session Date:</span>
<span class="info-value">{{ booking.confirmed_datetime|date:"l, F d, Y" }} at {{ booking.confirmed_datetime|time:"g:i A" }}</span>
</div>
</div>
</div>
<div class="section">
<h2 class="section-title">Your Session Details</h2>
<div class="info-card">
<div class="info-item">
<span class="info-label">Video Meeting:</span>
<span class="info-value">
<a href="{{ booking.jitsi_meet_url }}" style="color: #0ea5e9; text-decoration: none;">
{{ booking.jitsi_meet_url }}
</a>
</span>
</div>
<div class="info-item">
<span class="info-label">Therapist:</span>
<span class="info-value">{{ booking.assigned_therapist.get_full_name }}</span>
</div>
<div class="info-item">
<span class="info-label">Duration:</span>
<span class="info-value">
{% if booking.appointment_type == 'initial-consultation' %}90 minutes
{% elif booking.appointment_type == 'individual-therapy' %}60 minutes
{% elif booking.appointment_type == 'family-therapy' %}90 minutes
{% elif booking.appointment_type == 'couples-therapy' %}75 minutes
{% elif booking.appointment_type == 'group-therapy' %}90 minutes
{% elif booking.appointment_type == 'follow-up' %}45 minutes
{% else %}60 minutes{% endif %}
</span>
</div>
</div>
</div>
<div style="text-align: center; margin: 30px 0;">
<a href="{{ booking.jitsi_meet_url }}" class="button" style="background: linear-gradient(135deg, #10b981, #059669);">
🎥 Join Video Session
</a>
</div>
<div class="section">
<div style="background: #f0f9ff; padding: 20px; border-radius: 8px; text-align: center;">
<h3 style="color: #0369a1; margin-bottom: 10px;">📋 Receipt</h3>
<p style="margin: 5px 0;">Payment ID: {{ booking.stripe_payment_intent_id|default:booking.id }}</p>
<p style="margin: 5px 0;">Date: {{ booking.paid_at|date:"Y-m-d" }}</p>
<p style="margin: 5px 0;">Amount: ${{ booking.amount }}</p>
<p style="margin: 5px 0; font-size: 12px; color: #64748b;">
This email serves as your receipt for tax purposes.
</p>
</div>
</div>
<div class="section">
<p>If you have any questions about your payment or appointment, please don't hesitate to contact us.</p>
<div style="text-align: center; margin: 20px 0;">
<a href="tel:+19548073027" class="button" style="background: linear-gradient(135deg, #64748b, #475569);">
📞 Call (954) 807-3027
</a>
</div>
</div>
</div>
<div class="footer">
<p><strong>Attune Heart Therapy</strong></p>
<p>Thank you for trusting us with your care</p>
<div class="contact-info">
📞 (954) 807-3027<br>
✉️ hello@attunehearttherapy.com<br>
🌐 attunehearttherapy.com
</div>
<p style="font-size: 12px; color: #9ca3af; margin-top: 15px;">
Payment ID: {{ booking.stripe_payment_intent_id|default:booking.id }}<br>
Processed: {{ booking.paid_at|date:"Y-m-d H:i" }}
</p>
</div>
{% endblock %}

View File

@ -1,4 +1,4 @@
# Generated by Django 5.2.8 on 2025-11-22 02:11 # Generated by Django 5.2.8 on 2025-11-22 22:06
import django.db.models.deletion import django.db.models.deletion
from django.conf import settings from django.conf import settings

View File

@ -3,7 +3,6 @@ from rest_framework_simplejwt.views import TokenRefreshView
from . import views from . import views
urlpatterns = [ urlpatterns = [
path('', views.api_root, name='api-root'),
path('register/', views.register_user, name='register'), path('register/', views.register_user, name='register'),
path('login/', views.login_user, name='login'), path('login/', views.login_user, name='login'),

View File

@ -12,185 +12,6 @@ from datetime import timedelta
from rest_framework.reverse import reverse from rest_framework.reverse import reverse
@api_view(['GET'])
@permission_classes([AllowAny])
def api_root(request, format=None):
"""
# Authentication API Documentation
Welcome to the Authentication API. This service provides complete user authentication functionality including registration, email verification, login, and password reset using OTP.
## Base URL
```
{{ request.build_absolute_uri }}
```
## Quick Start
1. **Register** a new user account
2. **Verify** email with OTP sent to your email
3. **Login** with your credentials
4. Use the **access token** for authenticated requests
## API Endpoints
"""
endpoints = {
'documentation': {
'description': 'This API documentation',
'url': request.build_absolute_uri(),
'methods': ['GET']
},
'register': {
'description': 'Register a new user and send verification OTP',
'url': request.build_absolute_uri('register/'),
'methods': ['POST'],
'required_fields': ['email', 'first_name', 'last_name', 'password', 'password2'],
'example_request': {
'email': 'user@example.com',
'first_name': 'John',
'last_name': 'Doe',
'phone_number': '+1234567890',
'password': 'SecurePassword123',
'password2': 'SecurePassword123'
}
},
'verify_otp': {
'description': 'Verify email address using OTP',
'url': request.build_absolute_uri('verify-otp/'),
'methods': ['POST'],
'required_fields': ['email', 'otp'],
'example_request': {
'email': 'user@example.com',
'otp': '123456'
}
},
'login': {
'description': 'Authenticate user and return JWT tokens',
'url': request.build_absolute_uri('login/'),
'methods': ['POST'],
'required_fields': ['email', 'password'],
'example_request': {
'email': 'user@example.com',
'password': 'SecurePassword123'
}
},
'resend_otp': {
'description': 'Resend OTP for email verification or password reset',
'url': request.build_absolute_uri('resend-otp/'),
'methods': ['POST'],
'required_fields': ['email'],
'optional_fields': ['context (registration/password_reset)'],
'example_request': {
'email': 'user@example.com',
'context': 'registration'
}
},
'forgot_password': {
'description': 'Initiate password reset process',
'url': request.build_absolute_uri('forgot-password/'),
'methods': ['POST'],
'required_fields': ['email'],
'example_request': {
'email': 'user@example.com'
}
},
'verify_password_reset_otp': {
'description': 'Verify OTP for password reset',
'url': request.build_absolute_uri('verify-password-reset-otp/'),
'methods': ['POST'],
'required_fields': ['email', 'otp'],
'example_request': {
'email': 'user@example.com',
'otp': '123456'
}
},
'reset_password': {
'description': 'Reset password after OTP verification',
'url': request.build_absolute_uri('reset-password/'),
'methods': ['POST'],
'required_fields': ['email', 'otp', 'new_password', 'confirm_password'],
'example_request': {
'email': 'user@example.com',
'otp': '123456',
'new_password': 'NewSecurePassword123',
'confirm_password': 'NewSecurePassword123'
}
},
'token_refresh': {
'description': 'Refresh access token using refresh token',
'url': request.build_absolute_uri('token/refresh/'),
'methods': ['POST'],
'required_fields': ['refresh'],
'example_request': {
'refresh': 'your_refresh_token_here'
}
}
}
return Response({
'message': 'Authentication API',
'version': '1.0.0',
'endpoints': endpoints,
'authentication_flows': {
'registration_flow': [
'1. POST /register/ - Register user and send OTP',
'2. POST /verify-otp/ - Verify email with OTP',
'3. POST /login/ - Login with credentials'
],
'password_reset_flow': [
'1. POST /forgot-password/ - Request password reset OTP',
'2. POST /verify-password-reset-otp/ - Verify OTP',
'3. POST /reset-password/ - Set new password'
],
'login_flow_unverified': [
'1. POST /login/ - Returns email not verified error',
'2. POST /resend-otp/ - Resend verification OTP',
'3. POST /verify-otp/ - Verify email',
'4. POST /login/ - Successful login'
]
},
'specifications': {
'otp': {
'length': 6,
'expiry_minutes': 10,
'delivery_method': 'email'
},
'tokens': {
'access_token_lifetime': '5 minutes',
'refresh_token_lifetime': '24 hours'
},
'password_requirements': [
'Minimum 8 characters',
'Cannot be entirely numeric',
'Cannot be too common',
'Should include uppercase, lowercase, and numbers'
]
},
'error_handling': {
'common_status_codes': {
'200': 'Success',
'201': 'Created',
'400': 'Bad Request (validation errors)',
'401': 'Unauthorized (invalid credentials)',
'403': 'Forbidden (unverified email, inactive account)',
'404': 'Not Found',
'500': 'Internal Server Error'
},
'error_response_format': {
'error': 'Error description',
'message': 'User-friendly message'
}
},
'security_notes': [
'Always use HTTPS in production',
'Store tokens securely (httpOnly cookies recommended)',
'Implement token refresh logic',
'Validate all inputs on frontend and backend',
'Handle token expiration gracefully'
]
})
@api_view(['POST']) @api_view(['POST'])
@permission_classes([AllowAny]) @permission_classes([AllowAny])
def register_user(request): def register_user(request):