Compare commits
No commits in common. "bac26a04873a75615d18f8bb5d1eb43bd6c7cd33" and "b85391d6321d1e312f7650f1d999260e25ce3552" have entirely different histories.
bac26a0487
...
b85391d632
2
.gitignore
vendored
2
.gitignore
vendored
@ -127,6 +127,8 @@ __pypackages__/
|
|||||||
celerybeat-schedule
|
celerybeat-schedule
|
||||||
celerybeat.pid
|
celerybeat.pid
|
||||||
|
|
||||||
|
meetings
|
||||||
|
|
||||||
# SageMath parsed files
|
# SageMath parsed files
|
||||||
*.sage.py
|
*.sage.py
|
||||||
|
|
||||||
|
|||||||
@ -34,7 +34,7 @@ INSTALLED_APPS = [
|
|||||||
'corsheaders',
|
'corsheaders',
|
||||||
|
|
||||||
'users',
|
'users',
|
||||||
'meetings',
|
# 'meetings',
|
||||||
]
|
]
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
@ -48,8 +48,6 @@ 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 = [
|
||||||
@ -72,25 +70,23 @@ 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.postgresql',
|
# 'ENGINE': 'django.db.backends.sqlite3',
|
||||||
# 'NAME': os.getenv('POSTGRES_DB'),
|
# 'NAME': BASE_DIR / 'db.sqlite3',
|
||||||
# 'USER': os.getenv('POSTGRES_USER'),
|
|
||||||
# 'PASSWORD': os.getenv('POSTGRES_PASSWORD'),
|
|
||||||
# 'HOST': os.getenv('POSTGRES_HOST', 'postgres'),
|
|
||||||
# 'PORT': os.getenv('POSTGRES_PORT', 5432),
|
|
||||||
# }
|
# }
|
||||||
# }
|
# }
|
||||||
|
|
||||||
ENCRYPTION_KEY = os.getenv('ENCRYPTION_KEY')
|
DATABASES = {
|
||||||
|
'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),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -117,8 +113,11 @@ 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')
|
||||||
@ -126,6 +125,27 @@ 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')
|
||||||
|
|
||||||
|
|||||||
@ -1,10 +1,8 @@
|
|||||||
from django.urls import path, include
|
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from .views import api_root
|
from django.urls import path, include
|
||||||
|
|
||||||
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/meetings/', include('meetings.urls')),
|
# path('api/', include('meetings.urls')),
|
||||||
path('', api_root, name='api-root'),
|
|
||||||
]
|
]
|
||||||
@ -1,367 +0,0 @@
|
|||||||
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'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@ -1,33 +0,0 @@
|
|||||||
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"
|
|
||||||
@ -1,6 +0,0 @@
|
|||||||
from django.apps import AppConfig
|
|
||||||
|
|
||||||
|
|
||||||
class MeetingsConfig(AppConfig):
|
|
||||||
default_auto_field = 'django.db.models.BigAutoField'
|
|
||||||
name = 'meetings'
|
|
||||||
@ -1,86 +0,0 @@
|
|||||||
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
|
|
||||||
@ -1,52 +0,0 @@
|
|||||||
# 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'],
|
|
||||||
},
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@ -1,41 +0,0 @@
|
|||||||
# 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'),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@ -1,300 +0,0 @@
|
|||||||
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')}"
|
|
||||||
@ -1,98 +0,0 @@
|
|||||||
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)
|
|
||||||
@ -1,273 +0,0 @@
|
|||||||
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)}")
|
|
||||||
@ -1,3 +0,0 @@
|
|||||||
from django.test import TestCase
|
|
||||||
|
|
||||||
# Create your tests here.
|
|
||||||
@ -1,28 +0,0 @@
|
|||||||
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'),
|
|
||||||
]
|
|
||||||
@ -1,168 +0,0 @@
|
|||||||
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
|
|
||||||
})
|
|
||||||
100
templates/emails/admin_booking_notification.html
Normal file
100
templates/emails/admin_booking_notification.html
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
{% 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 %}
|
||||||
@ -1,304 +0,0 @@
|
|||||||
<!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>
|
|
||||||
@ -1,309 +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 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>
|
|
||||||
@ -1,288 +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);
|
|
||||||
}
|
|
||||||
|
|
||||||
.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>
|
|
||||||
145
templates/emails/booking_confirmed.html
Normal file
145
templates/emails/booking_confirmed.html
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
{% 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 %}
|
||||||
86
templates/emails/patient_booking_confirmation.html
Normal file
86
templates/emails/patient_booking_confirmation.html
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
{% 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 %}
|
||||||
112
templates/emails/payment_confirmed.html
Normal file
112
templates/emails/payment_confirmed.html
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
{% 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 %}
|
||||||
@ -1,4 +1,4 @@
|
|||||||
# Generated by Django 5.2.8 on 2025-11-22 22:06
|
# Generated by Django 5.2.8 on 2025-11-22 02:11
|
||||||
|
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|||||||
@ -3,6 +3,7 @@ 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'),
|
||||||
|
|||||||
179
users/views.py
179
users/views.py
@ -12,6 +12,185 @@ 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):
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user