feature/meetings #7
2
.gitignore
vendored
2
.gitignore
vendored
@ -127,8 +127,6 @@ __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,6 +48,8 @@ MIDDLEWARE = [
|
|||||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
SITE_NAME = os.getenv('SITE_NAME', 'Attune Heart Therapy')
|
||||||
|
|
||||||
ROOT_URLCONF = 'booking_system.urls'
|
ROOT_URLCONF = 'booking_system.urls'
|
||||||
|
|
||||||
TEMPLATES = [
|
TEMPLATES = [
|
||||||
@ -70,23 +72,25 @@ TEMPLATES = [
|
|||||||
WSGI_APPLICATION = 'booking_system.wsgi.application'
|
WSGI_APPLICATION = 'booking_system.wsgi.application'
|
||||||
|
|
||||||
|
|
||||||
|
DATABASES = {
|
||||||
|
'default': {
|
||||||
|
'ENGINE': 'django.db.backends.sqlite3',
|
||||||
|
'NAME': BASE_DIR / 'db.sqlite3',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
# DATABASES = {
|
# DATABASES = {
|
||||||
# 'default': {
|
# 'default': {
|
||||||
# 'ENGINE': 'django.db.backends.sqlite3',
|
# 'ENGINE': 'django.db.backends.postgresql',
|
||||||
# 'NAME': BASE_DIR / 'db.sqlite3',
|
# 'NAME': os.getenv('POSTGRES_DB'),
|
||||||
|
# 'USER': os.getenv('POSTGRES_USER'),
|
||||||
|
# 'PASSWORD': os.getenv('POSTGRES_PASSWORD'),
|
||||||
|
# 'HOST': os.getenv('POSTGRES_HOST', 'postgres'),
|
||||||
|
# 'PORT': os.getenv('POSTGRES_PORT', 5432),
|
||||||
# }
|
# }
|
||||||
# }
|
# }
|
||||||
|
|
||||||
DATABASES = {
|
ENCRYPTION_KEY = os.getenv('ENCRYPTION_KEY')
|
||||||
'default': {
|
|
||||||
'ENGINE': 'django.db.backends.postgresql',
|
|
||||||
'NAME': os.getenv('POSTGRES_DB'),
|
|
||||||
'USER': os.getenv('POSTGRES_USER'),
|
|
||||||
'PASSWORD': os.getenv('POSTGRES_PASSWORD'),
|
|
||||||
'HOST': os.getenv('POSTGRES_HOST', 'postgres'),
|
|
||||||
'PORT': os.getenv('POSTGRES_PORT', 5432),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -113,11 +117,8 @@ SIMPLE_JWT = {
|
|||||||
'BLACKLIST_AFTER_ROTATION': True,
|
'BLACKLIST_AFTER_ROTATION': True,
|
||||||
'SIGNING_KEY': os.getenv('JWT_SECRET', SECRET_KEY),
|
'SIGNING_KEY': os.getenv('JWT_SECRET', SECRET_KEY),
|
||||||
'AUTH_HEADER_TYPES': ('Bearer',),
|
'AUTH_HEADER_TYPES': ('Bearer',),
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# HIPAA Email Configuration
|
|
||||||
EMAIL_ENCRYPTION_KEY = os.getenv('EMAIL_ENCRYPTION_KEY')
|
|
||||||
|
|
||||||
# Stripe Configuration
|
# Stripe Configuration
|
||||||
STRIPE_PUBLISHABLE_KEY = os.getenv('STRIPE_PUBLISHABLE_KEY')
|
STRIPE_PUBLISHABLE_KEY = os.getenv('STRIPE_PUBLISHABLE_KEY')
|
||||||
@ -125,27 +126,6 @@ STRIPE_SECRET_KEY = os.getenv('STRIPE_SECRET_KEY')
|
|||||||
STRIPE_WEBHOOK_SECRET = os.getenv('STRIPE_WEBHOOK_SECRET')
|
STRIPE_WEBHOOK_SECRET = os.getenv('STRIPE_WEBHOOK_SECRET')
|
||||||
|
|
||||||
|
|
||||||
# HIPAA Compliance Settings
|
|
||||||
HIPAA_EMAIL_CONFIG = {
|
|
||||||
'ENCRYPTION_STANDARD': 'AES-256',
|
|
||||||
'REQUIRE_SECURE_PORTAL': True,
|
|
||||||
'AUDIT_RETENTION_DAYS': 365 * 6,
|
|
||||||
'AUTO_DELETE_UNREAD_DAYS': 30,
|
|
||||||
'REQUIRE_BAA': True,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# Secure Portal URL
|
|
||||||
SECURE_PORTAL_URL = os.getenv('SECURE_PORTAL_URL', 'https://secure.yourdomain.com')
|
|
||||||
|
|
||||||
# Business Associate Agreement Verification
|
|
||||||
BAA_VERIFICATION = {
|
|
||||||
'EMAIL_PROVIDER': os.getenv('EMAIL_PROVIDER'),
|
|
||||||
'BAA_SIGNED': os.getenv('BAA_SIGNED', 'False').lower() == 'true',
|
|
||||||
'BAA_EXPIRY': os.getenv('BAA_EXPIRY'),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# Jitsi Configuration
|
# Jitsi Configuration
|
||||||
JITSI_BASE_URL = os.getenv('JITSI_BASE_URL', 'https://meet.jit.si')
|
JITSI_BASE_URL = os.getenv('JITSI_BASE_URL', 'https://meet.jit.si')
|
||||||
|
|
||||||
|
|||||||
@ -1,8 +1,10 @@
|
|||||||
from django.contrib import admin
|
|
||||||
from django.urls import path, include
|
from django.urls import path, include
|
||||||
|
from django.contrib import admin
|
||||||
|
from .views import api_root
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('admin/', admin.site.urls),
|
path('admin/', admin.site.urls),
|
||||||
path('api/auth/', include('users.urls')),
|
path('api/auth/', include('users.urls')),
|
||||||
# path('api/', include('meetings.urls')),
|
path('api/meetings/', include('meetings.urls')),
|
||||||
|
path('', api_root, name='api-root'),
|
||||||
]
|
]
|
||||||
367
booking_system/views.py
Normal file
367
booking_system/views.py
Normal file
@ -0,0 +1,367 @@
|
|||||||
|
from rest_framework.decorators import api_view, permission_classes
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.permissions import AllowAny
|
||||||
|
|
||||||
|
@api_view(['GET'])
|
||||||
|
@permission_classes([AllowAny])
|
||||||
|
def api_root(request, format=None):
|
||||||
|
base_url = request.build_absolute_uri('/api/')
|
||||||
|
|
||||||
|
endpoints = {
|
||||||
|
'authentication': {
|
||||||
|
'description': 'User authentication and management endpoints',
|
||||||
|
'base_path': '/api/auth/',
|
||||||
|
'endpoints': {
|
||||||
|
'register': {
|
||||||
|
'description': 'Register a new user and send verification OTP',
|
||||||
|
'url': request.build_absolute_uri('/api/auth/register/'),
|
||||||
|
'methods': ['POST'],
|
||||||
|
'required_fields': ['email', 'first_name', 'last_name', 'password', 'password2'],
|
||||||
|
'example_request': {
|
||||||
|
'email': 'user@example.com',
|
||||||
|
'first_name': 'John',
|
||||||
|
'last_name': 'Doe',
|
||||||
|
'phone_number': '+1234567890',
|
||||||
|
'password': 'SecurePassword123',
|
||||||
|
'password2': 'SecurePassword123'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'verify_otp': {
|
||||||
|
'description': 'Verify email address using OTP',
|
||||||
|
'url': request.build_absolute_uri('/api/auth/verify-otp/'),
|
||||||
|
'methods': ['POST'],
|
||||||
|
'required_fields': ['email', 'otp'],
|
||||||
|
'example_request': {
|
||||||
|
'email': 'user@example.com',
|
||||||
|
'otp': '123456'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'login': {
|
||||||
|
'description': 'Authenticate user and return JWT tokens',
|
||||||
|
'url': request.build_absolute_uri('/api/auth/login/'),
|
||||||
|
'methods': ['POST'],
|
||||||
|
'required_fields': ['email', 'password'],
|
||||||
|
'example_request': {
|
||||||
|
'email': 'user@example.com',
|
||||||
|
'password': 'SecurePassword123'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'resend_otp': {
|
||||||
|
'description': 'Resend OTP for email verification or password reset',
|
||||||
|
'url': request.build_absolute_uri('/api/auth/resend-otp/'),
|
||||||
|
'methods': ['POST'],
|
||||||
|
'required_fields': ['email'],
|
||||||
|
'optional_fields': ['context (registration/password_reset)'],
|
||||||
|
'example_request': {
|
||||||
|
'email': 'user@example.com',
|
||||||
|
'context': 'registration'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'forgot_password': {
|
||||||
|
'description': 'Initiate password reset process',
|
||||||
|
'url': request.build_absolute_uri('/api/auth/forgot-password/'),
|
||||||
|
'methods': ['POST'],
|
||||||
|
'required_fields': ['email'],
|
||||||
|
'example_request': {
|
||||||
|
'email': 'user@example.com'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'verify_password_reset_otp': {
|
||||||
|
'description': 'Verify OTP for password reset',
|
||||||
|
'url': request.build_absolute_uri('/api/auth/verify-password-reset-otp/'),
|
||||||
|
'methods': ['POST'],
|
||||||
|
'required_fields': ['email', 'otp'],
|
||||||
|
'example_request': {
|
||||||
|
'email': 'user@example.com',
|
||||||
|
'otp': '123456'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'reset_password': {
|
||||||
|
'description': 'Reset password after OTP verification',
|
||||||
|
'url': request.build_absolute_uri('/api/auth/reset-password/'),
|
||||||
|
'methods': ['POST'],
|
||||||
|
'required_fields': ['email', 'otp', 'new_password', 'confirm_password'],
|
||||||
|
'example_request': {
|
||||||
|
'email': 'user@example.com',
|
||||||
|
'otp': '123456',
|
||||||
|
'new_password': 'NewSecurePassword123',
|
||||||
|
'confirm_password': 'NewSecurePassword123'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'token_refresh': {
|
||||||
|
'description': 'Refresh access token using refresh token',
|
||||||
|
'url': request.build_absolute_uri('/api/auth/token/refresh/'),
|
||||||
|
'methods': ['POST'],
|
||||||
|
'required_fields': ['refresh'],
|
||||||
|
'example_request': {
|
||||||
|
'refresh': 'your_refresh_token_here'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"appointments": {
|
||||||
|
"description": "Appointment request and management system with Jitsi video meetings",
|
||||||
|
"base_path": "/api/meetings/",
|
||||||
|
"endpoints": {
|
||||||
|
"admin_availability": {
|
||||||
|
"description": "Get or update admin weekly availability (Admin only)",
|
||||||
|
"url": "http://127.0.0.1:8000/api/meetings/admin/availability/",
|
||||||
|
"methods": ["GET", "PUT", "PATCH"],
|
||||||
|
"authentication": "Required (Staff users only)",
|
||||||
|
"response_fields": {
|
||||||
|
"available_days": "List of weekday numbers (0-6) when appointments are accepted",
|
||||||
|
"available_days_display": "Human-readable day names"
|
||||||
|
},
|
||||||
|
"example_request": {
|
||||||
|
"available_days": [0, 1, 2, 3, 4]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"available_dates": {
|
||||||
|
"description": "Get available appointment dates for the next 30 days (Public)",
|
||||||
|
"url": "http://127.0.0.1:8000/api/meetings/appointments/available-dates/",
|
||||||
|
"methods": ["GET"],
|
||||||
|
"authentication": "None required",
|
||||||
|
"response": "List of available dates in YYYY-MM-DD format"
|
||||||
|
},
|
||||||
|
"create_appointment": {
|
||||||
|
"description": "Create a new appointment request (Public)",
|
||||||
|
"url": "http://127.0.0.1:8000/api/meetings/appointments/create/",
|
||||||
|
"methods": ["POST"],
|
||||||
|
"authentication": "None required",
|
||||||
|
"required_fields": [
|
||||||
|
"first_name", "last_name", "email",
|
||||||
|
"preferred_dates", "preferred_time_slots"
|
||||||
|
],
|
||||||
|
"optional_fields": ["phone", "reason"],
|
||||||
|
"example_request": {
|
||||||
|
"first_name": "John",
|
||||||
|
"last_name": "Doe",
|
||||||
|
"email": "john@example.com",
|
||||||
|
"phone": "+1234567890",
|
||||||
|
"reason": "Initial consultation for anxiety",
|
||||||
|
"preferred_dates": ["2024-01-15", "2024-01-16"],
|
||||||
|
"preferred_time_slots": ["morning", "afternoon"]
|
||||||
|
},
|
||||||
|
"validation": "Preferred dates must be within admin available days"
|
||||||
|
},
|
||||||
|
"list_appointments": {
|
||||||
|
"description": "List appointment requests (Admin sees all, users see their own)",
|
||||||
|
"url": "http://127.0.0.1:8000/api/meetings/appointments/",
|
||||||
|
"methods": ["GET"],
|
||||||
|
"authentication": "Required",
|
||||||
|
"query_parameters": {
|
||||||
|
"email": "For non-authenticated user lookup (simplified approach)"
|
||||||
|
},
|
||||||
|
"response_fields": {
|
||||||
|
"jitsi_meet_url": "Jitsi meeting URL (only for scheduled appointments)",
|
||||||
|
"jitsi_room_id": "Jitsi room ID",
|
||||||
|
"has_jitsi_meeting": "Boolean indicating if meeting is created",
|
||||||
|
"can_join_meeting": "Boolean indicating if meeting can be joined now",
|
||||||
|
"meeting_status": "Current meeting status"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"appointment_detail": {
|
||||||
|
"description": "Get detailed information about a specific appointment",
|
||||||
|
"url": "http://127.0.0.1:8000/api/meetings/appointments/<uuid:pk>/",
|
||||||
|
"methods": ["GET"],
|
||||||
|
"authentication": "Required",
|
||||||
|
"url_parameter": "pk (UUID of the appointment)",
|
||||||
|
"response_includes": "Jitsi meeting information for scheduled appointments"
|
||||||
|
},
|
||||||
|
"user_appointments": {
|
||||||
|
"description": "Get appointments for the authenticated user",
|
||||||
|
"url": "http://127.0.0.1:8000/api/meetings/user/appointments/",
|
||||||
|
"methods": ["GET"],
|
||||||
|
"authentication": "Required",
|
||||||
|
"response": "List of user's appointment requests with Jitsi meeting details"
|
||||||
|
},
|
||||||
|
"schedule_appointment": {
|
||||||
|
"description": "Schedule an appointment and automatically create Jitsi meeting (Admin only)",
|
||||||
|
"url": "http://127.0.0.1:8000/api/meetings/appointments/<uuid:pk>/schedule/",
|
||||||
|
"methods": ["POST"],
|
||||||
|
"authentication": "Required (Staff users only)",
|
||||||
|
"required_fields": ["scheduled_datetime"],
|
||||||
|
"optional_fields": ["scheduled_duration"],
|
||||||
|
"prerequisites": "Appointment must be in 'pending_review' status",
|
||||||
|
"example_request": {
|
||||||
|
"scheduled_datetime": "2024-01-15T10:00:00Z",
|
||||||
|
"scheduled_duration": 60
|
||||||
|
},
|
||||||
|
"side_effects": [
|
||||||
|
"Updates status to 'scheduled'",
|
||||||
|
"Automatically generates Jitsi meeting room",
|
||||||
|
"Creates unique Jitsi room ID and URL",
|
||||||
|
"Sends confirmation email to user with meeting link",
|
||||||
|
"Clears rejection reason if any"
|
||||||
|
],
|
||||||
|
"response_includes": {
|
||||||
|
"jitsi_meet_url": "Generated Jitsi meeting URL",
|
||||||
|
"jitsi_room_id": "Unique Jitsi room ID",
|
||||||
|
"has_jitsi_meeting": "true"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"reject_appointment": {
|
||||||
|
"description": "Reject an appointment request (Admin only)",
|
||||||
|
"url": "http://127.0.0.1:8000/api/meetings/appointments/<uuid:pk>/reject/",
|
||||||
|
"methods": ["POST"],
|
||||||
|
"authentication": "Required (Staff users only)",
|
||||||
|
"optional_fields": ["rejection_reason"],
|
||||||
|
"prerequisites": "Appointment must be in 'pending_review' status",
|
||||||
|
"example_request": {
|
||||||
|
"rejection_reason": "No availability for preferred dates"
|
||||||
|
},
|
||||||
|
"side_effects": [
|
||||||
|
"Updates status to 'rejected'",
|
||||||
|
"Clears Jitsi meeting information",
|
||||||
|
"Sends rejection email to user",
|
||||||
|
"Clears scheduled datetime if any"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"jitsi_meeting_info": {
|
||||||
|
"description": "Get Jitsi meeting information for a scheduled appointment",
|
||||||
|
"url": "http://127.0.0.1:8000/api/meetings/appointments/<uuid:pk>/jitsi-meeting/",
|
||||||
|
"methods": ["GET"],
|
||||||
|
"authentication": "Required",
|
||||||
|
"prerequisites": "Appointment must be in 'scheduled' status",
|
||||||
|
"response_fields": {
|
||||||
|
"meeting_url": "Jitsi meeting URL",
|
||||||
|
"room_id": "Jitsi room ID",
|
||||||
|
"scheduled_time": "Formatted scheduled datetime",
|
||||||
|
"duration": "Meeting duration display",
|
||||||
|
"can_join": "Boolean indicating if meeting can be joined now",
|
||||||
|
"meeting_status": "Current meeting status",
|
||||||
|
"join_instructions": "Instructions for joining the meeting"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"appointment_stats": {
|
||||||
|
"description": "Get appointment statistics and analytics (Admin only)",
|
||||||
|
"url": "http://127.0.0.1:8000/api/meetings/appointments/stats/",
|
||||||
|
"methods": ["GET"],
|
||||||
|
"authentication": "Required (Staff users only)",
|
||||||
|
"response_fields": {
|
||||||
|
"total_requests": "Total number of appointment requests",
|
||||||
|
"pending_review": "Number of pending review requests",
|
||||||
|
"scheduled": "Number of scheduled appointments",
|
||||||
|
"rejected": "Number of rejected requests",
|
||||||
|
"completion_rate": "Percentage of requests that were scheduled"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"jitsi_integration": {
|
||||||
|
"description": "Automatic Jitsi video meeting integration",
|
||||||
|
"features": [
|
||||||
|
"Automatic meeting room generation when appointment is scheduled",
|
||||||
|
"Unique room IDs for each therapy session",
|
||||||
|
"No setup required for clients - just click and join",
|
||||||
|
"Meeting availability based on scheduled time",
|
||||||
|
"Secure, encrypted video sessions"
|
||||||
|
],
|
||||||
|
"meeting_lifecycle": {
|
||||||
|
"pending": "No Jitsi meeting created",
|
||||||
|
"scheduled": "Jitsi meeting automatically generated with unique URL",
|
||||||
|
"active": "Meeting can be joined 10 minutes before scheduled time",
|
||||||
|
"completed": "Meeting ends 15 minutes after scheduled duration"
|
||||||
|
},
|
||||||
|
"join_conditions": [
|
||||||
|
"Appointment must be in 'scheduled' status",
|
||||||
|
"Current time must be within 10 minutes before to 15 minutes after scheduled end",
|
||||||
|
"Both client and therapist can join using the same URL"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'message': 'Therapy Appointment API',
|
||||||
|
'version': '1.0.0',
|
||||||
|
'base_url': base_url,
|
||||||
|
'project_structure': {
|
||||||
|
'admin': '/admin/ - Django admin interface',
|
||||||
|
'authentication': '/api/auth/ - User authentication and management',
|
||||||
|
'appointments': '/api/meetings/ - Appointment booking system'
|
||||||
|
},
|
||||||
|
'endpoints': endpoints,
|
||||||
|
|
||||||
|
'appointment_workflows': {
|
||||||
|
'client_booking_flow': [
|
||||||
|
'1. GET /api/meetings/appointments/available-dates/ - Check available dates',
|
||||||
|
'2. POST /api/meetings/appointments/create/ - Submit appointment request',
|
||||||
|
'3. GET /api/meetings/user/appointments/ - Track request status',
|
||||||
|
'4. Receive email notification when scheduled/rejected'
|
||||||
|
],
|
||||||
|
'admin_management_flow': [
|
||||||
|
'1. PUT /api/meetings/admin/availability/ - Set weekly availability',
|
||||||
|
'2. GET /api/meetings/appointments/ - Review pending requests',
|
||||||
|
'3. POST /api/meetings/appointments/{id}/schedule/ - Schedule appointment OR',
|
||||||
|
'4. POST /api/meetings/appointments/{id}/reject/ - Reject with reason',
|
||||||
|
'5. GET /api/meetings/appointments/stats/ - Monitor performance'
|
||||||
|
],
|
||||||
|
'status_lifecycle': [
|
||||||
|
'pending_review → scheduled (with datetime)',
|
||||||
|
'pending_review → rejected (with optional reason)'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
'authentication_flows': {
|
||||||
|
'registration_flow': [
|
||||||
|
'1. POST /api/auth/register/ - Register user and send OTP',
|
||||||
|
'2. POST /api/auth/verify-otp/ - Verify email with OTP',
|
||||||
|
'3. POST /api/auth/login/ - Login with credentials'
|
||||||
|
],
|
||||||
|
'password_reset_flow': [
|
||||||
|
'1. POST /api/auth/forgot-password/ - Request password reset OTP',
|
||||||
|
'2. POST /api/auth/verify-password-reset-otp/ - Verify OTP',
|
||||||
|
'3. POST /api/auth/reset-password/ - Set new password'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
'quick_start': {
|
||||||
|
'for_users': [
|
||||||
|
'1. Register: POST /api/auth/register/',
|
||||||
|
'2. Verify email: POST /api/auth/verify-otp/',
|
||||||
|
'3. Login: POST /api/auth/login/',
|
||||||
|
'4. Check availability: GET /api/meetings/appointments/available-dates/',
|
||||||
|
'5. Book appointment: POST /api/meetings/appointments/create/'
|
||||||
|
],
|
||||||
|
'for_admins': [
|
||||||
|
'1. Login to Django admin: /admin/',
|
||||||
|
'2. Set availability: PUT /api/meetings/admin/availability/',
|
||||||
|
'3. Manage appointments: GET /api/meetings/appointments/',
|
||||||
|
'4. Schedule/Reject: Use specific appointment endpoints'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
'data_specifications': {
|
||||||
|
'appointment': {
|
||||||
|
'status_choices': [
|
||||||
|
'pending_review - Initial state, awaiting admin action',
|
||||||
|
'scheduled - Approved with specific date/time',
|
||||||
|
'rejected - Not accepted, with optional reason'
|
||||||
|
],
|
||||||
|
'time_slot_choices': [
|
||||||
|
'morning - 9AM to 12PM',
|
||||||
|
'afternoon - 1PM to 5PM',
|
||||||
|
'evening - 6PM to 9PM'
|
||||||
|
],
|
||||||
|
'preferred_dates_format': 'YYYY-MM-DD (array of strings)',
|
||||||
|
'encrypted_fields': [
|
||||||
|
'first_name', 'last_name', 'email', 'phone',
|
||||||
|
'reason', 'rejection_reason'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
'availability': {
|
||||||
|
'day_format': '0=Monday, 1=Tuesday, ..., 6=Sunday',
|
||||||
|
'example': '[0, 1, 2, 3, 4] for Monday-Friday'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
'authentication_notes': {
|
||||||
|
'token_usage': 'Include JWT token in Authorization header: Bearer <token>',
|
||||||
|
'token_refresh': 'Use refresh token to get new access token when expired',
|
||||||
|
'permissions': {
|
||||||
|
'public_endpoints': 'No authentication required',
|
||||||
|
'user_endpoints': 'Valid JWT token required',
|
||||||
|
'admin_endpoints': 'Staff user with valid JWT token required'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
0
meetings/__init__.py
Normal file
0
meetings/__init__.py
Normal file
33
meetings/admin.py
Normal file
33
meetings/admin.py
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
from .models import AdminWeeklyAvailability, AppointmentRequest
|
||||||
|
|
||||||
|
@admin.register(AdminWeeklyAvailability)
|
||||||
|
class AdminWeeklyAvailabilityAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ['available_days_display', 'created_at']
|
||||||
|
|
||||||
|
def available_days_display(self, obj):
|
||||||
|
days_map = dict(AdminWeeklyAvailability.DAYS_OF_WEEK)
|
||||||
|
return ', '.join([days_map[day] for day in obj.available_days])
|
||||||
|
available_days_display.short_description = 'Available Days'
|
||||||
|
|
||||||
|
@admin.register(AppointmentRequest)
|
||||||
|
class AppointmentRequestAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ['full_name', 'email', 'status', 'created_at', 'scheduled_datetime']
|
||||||
|
list_filter = ['status', 'created_at']
|
||||||
|
search_fields = ['first_name', 'last_name', 'email']
|
||||||
|
readonly_fields = ['created_at', 'updated_at']
|
||||||
|
actions = ['mark_as_scheduled', 'mark_as_rejected']
|
||||||
|
|
||||||
|
def mark_as_scheduled(self, request, queryset):
|
||||||
|
for appointment in queryset:
|
||||||
|
if appointment.status == 'pending_review':
|
||||||
|
appointment.status = 'scheduled'
|
||||||
|
appointment.save()
|
||||||
|
mark_as_scheduled.short_description = "Mark selected as scheduled"
|
||||||
|
|
||||||
|
def mark_as_rejected(self, request, queryset):
|
||||||
|
for appointment in queryset:
|
||||||
|
if appointment.status == 'pending_review':
|
||||||
|
appointment.status = 'rejected'
|
||||||
|
appointment.save()
|
||||||
|
mark_as_rejected.short_description = "Mark selected as rejected"
|
||||||
6
meetings/apps.py
Normal file
6
meetings/apps.py
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class MeetingsConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'meetings'
|
||||||
90
meetings/email_service.py
Normal file
90
meetings/email_service.py
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
from django.core.mail import EmailMultiAlternatives
|
||||||
|
from django.template.loader import render_to_string
|
||||||
|
from django.utils.html import strip_tags
|
||||||
|
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_content = render_to_string('emails/admin_notification.html', context)
|
||||||
|
text_content = strip_tags(html_content)
|
||||||
|
|
||||||
|
admin_email = getattr(settings, 'ADMIN_EMAIL', 'hello@attunehearttherapy.com')
|
||||||
|
|
||||||
|
try:
|
||||||
|
email_msg = EmailMultiAlternatives(
|
||||||
|
subject,
|
||||||
|
text_content,
|
||||||
|
settings.DEFAULT_FROM_EMAIL,
|
||||||
|
[admin_email]
|
||||||
|
)
|
||||||
|
email_msg.attach_alternative(html_content, "text/html")
|
||||||
|
email_msg.send()
|
||||||
|
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_content = render_to_string('emails/appointment_scheduled.html', context)
|
||||||
|
text_content = strip_tags(html_content)
|
||||||
|
|
||||||
|
try:
|
||||||
|
email_msg = EmailMultiAlternatives(
|
||||||
|
subject,
|
||||||
|
text_content,
|
||||||
|
settings.DEFAULT_FROM_EMAIL,
|
||||||
|
[appointment.email]
|
||||||
|
)
|
||||||
|
email_msg.attach_alternative(html_content, "text/html")
|
||||||
|
email_msg.send()
|
||||||
|
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_content = render_to_string('emails/appointment_rejected.html', context)
|
||||||
|
text_content = strip_tags(html_content)
|
||||||
|
|
||||||
|
try:
|
||||||
|
email_msg = EmailMultiAlternatives(
|
||||||
|
subject,
|
||||||
|
text_content,
|
||||||
|
settings.DEFAULT_FROM_EMAIL,
|
||||||
|
[appointment.email]
|
||||||
|
)
|
||||||
|
email_msg.attach_alternative(html_content, "text/html")
|
||||||
|
email_msg.send()
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Failed to send rejection notification: {e}")
|
||||||
|
return False
|
||||||
52
meetings/migrations/0001_initial.py
Normal file
52
meetings/migrations/0001_initial.py
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
# Generated by Django 5.2.8 on 2025-11-22 22:06
|
||||||
|
|
||||||
|
import meetings.models
|
||||||
|
import uuid
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='AdminWeeklyAvailability',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('available_days', models.JSONField(default=list, help_text='List of weekdays (0-6) when appointments are accepted')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Admin Weekly Availability',
|
||||||
|
'verbose_name_plural': 'Admin Weekly Availability',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='AppointmentRequest',
|
||||||
|
fields=[
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
('first_name', meetings.models.EncryptedCharField(max_length=100)),
|
||||||
|
('last_name', meetings.models.EncryptedCharField(max_length=100)),
|
||||||
|
('email', meetings.models.EncryptedEmailField()),
|
||||||
|
('phone', meetings.models.EncryptedCharField(blank=True, max_length=20)),
|
||||||
|
('reason', meetings.models.EncryptedTextField(blank=True)),
|
||||||
|
('preferred_dates', models.JSONField(help_text='List of preferred dates (YYYY-MM-DD format)')),
|
||||||
|
('preferred_time_slots', models.JSONField(help_text='List of preferred time slots (morning/afternoon/evening)')),
|
||||||
|
('status', models.CharField(choices=[('pending_review', 'Pending Review'), ('scheduled', 'Scheduled'), ('rejected', 'Rejected')], default='pending_review', max_length=20)),
|
||||||
|
('scheduled_datetime', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('rejection_reason', meetings.models.EncryptedTextField(blank=True)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Appointment Request',
|
||||||
|
'verbose_name_plural': 'Appointment Requests',
|
||||||
|
'ordering': ['-created_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,41 @@
|
|||||||
|
# Generated by Django 5.2.8 on 2025-11-22 23:31
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('meetings', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='appointmentrequest',
|
||||||
|
name='jitsi_meet_url',
|
||||||
|
field=models.URLField(blank=True, help_text='Jitsi Meet URL for the video session'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='appointmentrequest',
|
||||||
|
name='jitsi_room_id',
|
||||||
|
field=models.CharField(blank=True, help_text='Jitsi room ID', max_length=100, unique=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='appointmentrequest',
|
||||||
|
name='scheduled_duration',
|
||||||
|
field=models.PositiveIntegerField(default=60, help_text='Duration in minutes'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='appointmentrequest',
|
||||||
|
name='status',
|
||||||
|
field=models.CharField(choices=[('pending_review', 'Pending Review'), ('scheduled', 'Scheduled'), ('rejected', 'Rejected'), ('completed', 'Completed'), ('cancelled', 'Cancelled')], default='pending_review', max_length=20),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='appointmentrequest',
|
||||||
|
index=models.Index(fields=['status', 'scheduled_datetime'], name='meetings_ap_status_4e4e26_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='appointmentrequest',
|
||||||
|
index=models.Index(fields=['email', 'created_at'], name='meetings_ap_email_b8ed9d_idx'),
|
||||||
|
),
|
||||||
|
]
|
||||||
0
meetings/migrations/__init__.py
Normal file
0
meetings/migrations/__init__.py
Normal file
300
meetings/models.py
Normal file
300
meetings/models.py
Normal file
@ -0,0 +1,300 @@
|
|||||||
|
import uuid
|
||||||
|
from django.db import models
|
||||||
|
from django.conf import settings
|
||||||
|
from django.utils import timezone
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
|
from cryptography.hazmat.primitives import hashes
|
||||||
|
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
||||||
|
import base64
|
||||||
|
import os
|
||||||
|
|
||||||
|
class EncryptionManager:
|
||||||
|
def __init__(self):
|
||||||
|
self.fernet = self._get_fernet()
|
||||||
|
|
||||||
|
def _get_fernet_key(self):
|
||||||
|
key = getattr(settings, 'ENCRYPTION_KEY', None) or os.environ.get('ENCRYPTION_KEY')
|
||||||
|
if not key:
|
||||||
|
key = Fernet.generate_key().decode()
|
||||||
|
key = key.encode()
|
||||||
|
return key
|
||||||
|
|
||||||
|
def _get_fernet(self):
|
||||||
|
key = self._get_fernet_key()
|
||||||
|
return Fernet(key)
|
||||||
|
|
||||||
|
def encrypt_value(self, value):
|
||||||
|
if value is None or value == "":
|
||||||
|
return value
|
||||||
|
encrypted_value = self.fernet.encrypt(value.encode())
|
||||||
|
return base64.urlsafe_b64encode(encrypted_value).decode()
|
||||||
|
|
||||||
|
def decrypt_value(self, encrypted_value):
|
||||||
|
if encrypted_value is None or encrypted_value == "":
|
||||||
|
return encrypted_value
|
||||||
|
try:
|
||||||
|
encrypted_bytes = base64.urlsafe_b64decode(encrypted_value.encode())
|
||||||
|
decrypted_value = self.fernet.decrypt(encrypted_bytes)
|
||||||
|
return decrypted_value.decode()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Decryption error: {e}")
|
||||||
|
return encrypted_value
|
||||||
|
|
||||||
|
encryption_manager = EncryptionManager()
|
||||||
|
|
||||||
|
class EncryptedCharField(models.CharField):
|
||||||
|
def from_db_value(self, value, expression, connection):
|
||||||
|
if value is None:
|
||||||
|
return value
|
||||||
|
return encryption_manager.decrypt_value(value)
|
||||||
|
|
||||||
|
def get_prep_value(self, value):
|
||||||
|
if value is None:
|
||||||
|
return value
|
||||||
|
return encryption_manager.encrypt_value(value)
|
||||||
|
|
||||||
|
class EncryptedEmailField(EncryptedCharField):
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
kwargs['max_length'] = 254
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
def from_db_value(self, value, expression, connection):
|
||||||
|
if value is None:
|
||||||
|
return value
|
||||||
|
return encryption_manager.decrypt_value(value)
|
||||||
|
|
||||||
|
def get_prep_value(self, value):
|
||||||
|
if value is None:
|
||||||
|
return value
|
||||||
|
return encryption_manager.encrypt_value(value)
|
||||||
|
|
||||||
|
class EncryptedTextField(models.TextField):
|
||||||
|
def from_db_value(self, value, expression, connection):
|
||||||
|
if value is None:
|
||||||
|
return value
|
||||||
|
return encryption_manager.decrypt_value(value)
|
||||||
|
|
||||||
|
def get_prep_value(self, value):
|
||||||
|
if value is None:
|
||||||
|
return value
|
||||||
|
return encryption_manager.encrypt_value(value)
|
||||||
|
|
||||||
|
class AdminWeeklyAvailability(models.Model):
|
||||||
|
DAYS_OF_WEEK = [
|
||||||
|
(0, 'Monday'),
|
||||||
|
(1, 'Tuesday'),
|
||||||
|
(2, 'Wednesday'),
|
||||||
|
(3, 'Thursday'),
|
||||||
|
(4, 'Friday'),
|
||||||
|
(5, 'Saturday'),
|
||||||
|
(6, 'Sunday'),
|
||||||
|
]
|
||||||
|
|
||||||
|
available_days = models.JSONField(
|
||||||
|
default=list,
|
||||||
|
help_text="List of weekdays (0-6) when appointments are accepted"
|
||||||
|
)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = 'Admin Weekly Availability'
|
||||||
|
verbose_name_plural = 'Admin Weekly Availability'
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
days = [self.DAYS_OF_WEEK[day][1] for day in self.available_days]
|
||||||
|
return f"Available: {', '.join(days)}"
|
||||||
|
|
||||||
|
class AppointmentRequest(models.Model):
|
||||||
|
STATUS_CHOICES = [
|
||||||
|
('pending_review', 'Pending Review'),
|
||||||
|
('scheduled', 'Scheduled'),
|
||||||
|
('rejected', 'Rejected'),
|
||||||
|
('completed', 'Completed'),
|
||||||
|
('cancelled', 'Cancelled'),
|
||||||
|
]
|
||||||
|
|
||||||
|
TIME_SLOT_CHOICES = [
|
||||||
|
('morning', 'Morning (9AM - 12PM)'),
|
||||||
|
('afternoon', 'Afternoon (1PM - 5PM)'),
|
||||||
|
('evening', 'Evening (6PM - 9PM)'),
|
||||||
|
]
|
||||||
|
|
||||||
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||||
|
|
||||||
|
first_name = EncryptedCharField(max_length=100)
|
||||||
|
last_name = EncryptedCharField(max_length=100)
|
||||||
|
email = EncryptedEmailField()
|
||||||
|
phone = EncryptedCharField(max_length=20, blank=True)
|
||||||
|
reason = EncryptedTextField(blank=True)
|
||||||
|
|
||||||
|
preferred_dates = models.JSONField(
|
||||||
|
help_text="List of preferred dates (YYYY-MM-DD format)"
|
||||||
|
)
|
||||||
|
preferred_time_slots = models.JSONField(
|
||||||
|
help_text="List of preferred time slots (morning/afternoon/evening)"
|
||||||
|
)
|
||||||
|
|
||||||
|
status = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=STATUS_CHOICES,
|
||||||
|
default='pending_review'
|
||||||
|
)
|
||||||
|
|
||||||
|
scheduled_datetime = models.DateTimeField(null=True, blank=True)
|
||||||
|
scheduled_duration = models.PositiveIntegerField(
|
||||||
|
default=60,
|
||||||
|
help_text="Duration in minutes"
|
||||||
|
)
|
||||||
|
rejection_reason = EncryptedTextField(blank=True)
|
||||||
|
|
||||||
|
jitsi_meet_url = models.URLField(blank=True, help_text="Jitsi Meet URL for the video session")
|
||||||
|
jitsi_room_id = models.CharField(max_length=100, unique=True, blank=True, help_text="Jitsi room ID")
|
||||||
|
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-created_at']
|
||||||
|
verbose_name = 'Appointment Request'
|
||||||
|
verbose_name_plural = 'Appointment Requests'
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['status', 'scheduled_datetime']),
|
||||||
|
models.Index(fields=['email', 'created_at']),
|
||||||
|
]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def full_name(self):
|
||||||
|
return f"{self.first_name} {self.last_name}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def formatted_created_at(self):
|
||||||
|
return self.created_at.strftime("%B %d, %Y at %I:%M %p")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def formatted_scheduled_datetime(self):
|
||||||
|
if self.scheduled_datetime:
|
||||||
|
return self.scheduled_datetime.strftime("%B %d, %Y at %I:%M %p")
|
||||||
|
return None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_jitsi_meeting(self):
|
||||||
|
return bool(self.jitsi_meet_url and self.jitsi_room_id)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def meeting_in_future(self):
|
||||||
|
if not self.scheduled_datetime:
|
||||||
|
return False
|
||||||
|
return self.scheduled_datetime > timezone.now()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def meeting_duration_display(self):
|
||||||
|
hours = self.scheduled_duration // 60
|
||||||
|
minutes = self.scheduled_duration % 60
|
||||||
|
if hours > 0:
|
||||||
|
return f"{hours}h {minutes}m"
|
||||||
|
return f"{minutes}m"
|
||||||
|
|
||||||
|
def get_preferred_dates_display(self):
|
||||||
|
try:
|
||||||
|
dates = [timezone.datetime.strptime(date, '%Y-%m-%d').strftime('%b %d, %Y')
|
||||||
|
for date in self.preferred_dates]
|
||||||
|
return ', '.join(dates)
|
||||||
|
except:
|
||||||
|
return ', '.join(self.preferred_dates)
|
||||||
|
|
||||||
|
def get_preferred_time_slots_display(self):
|
||||||
|
slot_display = {
|
||||||
|
'morning': 'Morning',
|
||||||
|
'afternoon': 'Afternoon',
|
||||||
|
'evening': 'Evening'
|
||||||
|
}
|
||||||
|
return ', '.join([slot_display.get(slot, slot) for slot in self.preferred_time_slots])
|
||||||
|
|
||||||
|
def generate_jitsi_room_id(self):
|
||||||
|
if not self.jitsi_room_id:
|
||||||
|
self.jitsi_room_id = f"therapy_session_{self.id.hex[:16]}"
|
||||||
|
return self.jitsi_room_id
|
||||||
|
|
||||||
|
def create_jitsi_meeting(self):
|
||||||
|
if not self.jitsi_room_id:
|
||||||
|
self.generate_jitsi_room_id()
|
||||||
|
|
||||||
|
jitsi_base_url = getattr(settings, 'JITSI_BASE_URL', 'https://meet.jit.si')
|
||||||
|
self.jitsi_meet_url = f"{jitsi_base_url}/{self.jitsi_room_id}"
|
||||||
|
return self.jitsi_meet_url
|
||||||
|
|
||||||
|
def get_jitsi_join_info(self):
|
||||||
|
if not self.has_jitsi_meeting:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return {
|
||||||
|
'meeting_url': self.jitsi_meet_url,
|
||||||
|
'room_id': self.jitsi_room_id,
|
||||||
|
'scheduled_time': self.formatted_scheduled_datetime,
|
||||||
|
'duration': self.meeting_duration_display,
|
||||||
|
'join_instructions': 'Click the meeting URL to join the video session. No password required.'
|
||||||
|
}
|
||||||
|
|
||||||
|
def schedule_appointment(self, datetime_obj, duration=60, commit=True):
|
||||||
|
self.status = 'scheduled'
|
||||||
|
self.scheduled_datetime = datetime_obj
|
||||||
|
self.scheduled_duration = duration
|
||||||
|
self.rejection_reason = ''
|
||||||
|
|
||||||
|
self.create_jitsi_meeting()
|
||||||
|
|
||||||
|
if commit:
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
def reject_appointment(self, reason='', commit=True):
|
||||||
|
self.status = 'rejected'
|
||||||
|
self.rejection_reason = reason
|
||||||
|
self.scheduled_datetime = None
|
||||||
|
self.jitsi_meet_url = ''
|
||||||
|
self.jitsi_room_id = ''
|
||||||
|
if commit:
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
def cancel_appointment(self, reason='', commit=True):
|
||||||
|
self.status = 'cancelled'
|
||||||
|
self.rejection_reason = reason
|
||||||
|
if commit:
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
def complete_appointment(self, commit=True):
|
||||||
|
self.status = 'completed'
|
||||||
|
if commit:
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
def can_join_meeting(self):
|
||||||
|
if not self.scheduled_datetime or not self.has_jitsi_meeting:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if self.status != 'scheduled':
|
||||||
|
return False
|
||||||
|
|
||||||
|
now = timezone.now()
|
||||||
|
meeting_start = self.scheduled_datetime
|
||||||
|
meeting_end = meeting_start + timezone.timedelta(minutes=self.scheduled_duration + 15) # 15 min buffer
|
||||||
|
|
||||||
|
return meeting_start - timezone.timedelta(minutes=10) <= now <= meeting_end
|
||||||
|
|
||||||
|
def get_meeting_status(self):
|
||||||
|
if not self.scheduled_datetime:
|
||||||
|
return "Not scheduled"
|
||||||
|
|
||||||
|
now = timezone.now()
|
||||||
|
meeting_start = self.scheduled_datetime
|
||||||
|
|
||||||
|
if now < meeting_start - timezone.timedelta(minutes=10):
|
||||||
|
return "Scheduled"
|
||||||
|
elif self.can_join_meeting():
|
||||||
|
return "Ready to join"
|
||||||
|
elif now > meeting_start + timezone.timedelta(minutes=self.scheduled_duration):
|
||||||
|
return "Completed"
|
||||||
|
else:
|
||||||
|
return "Ended"
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.full_name} - {self.get_status_display()} - {self.created_at.strftime('%Y-%m-%d')}"
|
||||||
98
meetings/serializers.py
Normal file
98
meetings/serializers.py
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
from rest_framework import serializers
|
||||||
|
from .models import AdminWeeklyAvailability, AppointmentRequest
|
||||||
|
from django.utils import timezone
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
import json
|
||||||
|
|
||||||
|
class AdminWeeklyAvailabilitySerializer(serializers.ModelSerializer):
|
||||||
|
available_days_display = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = AdminWeeklyAvailability
|
||||||
|
fields = ['id', 'available_days', 'available_days_display', 'created_at', 'updated_at']
|
||||||
|
|
||||||
|
def get_available_days_display(self, obj):
|
||||||
|
days_map = dict(AdminWeeklyAvailability.DAYS_OF_WEEK)
|
||||||
|
return [days_map[day] for day in obj.available_days]
|
||||||
|
|
||||||
|
class AppointmentRequestSerializer(serializers.ModelSerializer):
|
||||||
|
full_name = serializers.ReadOnlyField()
|
||||||
|
formatted_created_at = serializers.ReadOnlyField()
|
||||||
|
formatted_scheduled_datetime = serializers.ReadOnlyField()
|
||||||
|
preferred_dates_display = serializers.ReadOnlyField()
|
||||||
|
preferred_time_slots_display = serializers.ReadOnlyField()
|
||||||
|
has_jitsi_meeting = serializers.ReadOnlyField()
|
||||||
|
jitsi_meet_url = serializers.ReadOnlyField()
|
||||||
|
jitsi_room_id = serializers.ReadOnlyField()
|
||||||
|
can_join_meeting = serializers.ReadOnlyField()
|
||||||
|
meeting_status = serializers.ReadOnlyField()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = AppointmentRequest
|
||||||
|
fields = [
|
||||||
|
'id', 'first_name', 'last_name', 'email', 'phone', 'reason',
|
||||||
|
'preferred_dates', 'preferred_time_slots', 'status',
|
||||||
|
'scheduled_datetime', 'scheduled_duration', 'rejection_reason',
|
||||||
|
'jitsi_meet_url', 'jitsi_room_id', 'created_at', 'updated_at',
|
||||||
|
'full_name', 'formatted_created_at', 'formatted_scheduled_datetime',
|
||||||
|
'preferred_dates_display', 'preferred_time_slots_display',
|
||||||
|
'has_jitsi_meeting', 'can_join_meeting', 'meeting_status'
|
||||||
|
]
|
||||||
|
read_only_fields = [
|
||||||
|
'id', 'status', 'scheduled_datetime', 'scheduled_duration',
|
||||||
|
'rejection_reason', 'jitsi_meet_url', 'jitsi_room_id',
|
||||||
|
'created_at', 'updated_at'
|
||||||
|
]
|
||||||
|
|
||||||
|
class AppointmentRequestCreateSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = AppointmentRequest
|
||||||
|
fields = [
|
||||||
|
'first_name', 'last_name', 'email', 'phone', 'reason',
|
||||||
|
'preferred_dates', 'preferred_time_slots'
|
||||||
|
]
|
||||||
|
|
||||||
|
def validate_preferred_dates(self, value):
|
||||||
|
if not value or len(value) == 0:
|
||||||
|
raise serializers.ValidationError("At least one preferred date is required.")
|
||||||
|
|
||||||
|
today = timezone.now().date()
|
||||||
|
for date_str in value:
|
||||||
|
try:
|
||||||
|
date_obj = datetime.strptime(date_str, '%Y-%m-%d').date()
|
||||||
|
if date_obj < today:
|
||||||
|
raise serializers.ValidationError("Preferred dates cannot be in the past.")
|
||||||
|
except ValueError:
|
||||||
|
raise serializers.ValidationError(f"Invalid date format: {date_str}. Use YYYY-MM-DD.")
|
||||||
|
|
||||||
|
return value
|
||||||
|
|
||||||
|
def validate_preferred_time_slots(self, value):
|
||||||
|
if not value or len(value) == 0:
|
||||||
|
raise serializers.ValidationError("At least one time slot is required.")
|
||||||
|
|
||||||
|
valid_slots = ['morning', 'afternoon', 'evening']
|
||||||
|
for slot in value:
|
||||||
|
if slot not in valid_slots:
|
||||||
|
raise serializers.ValidationError(f"Invalid time slot: {slot}. Must be one of {valid_slots}.")
|
||||||
|
|
||||||
|
return value
|
||||||
|
|
||||||
|
class AppointmentScheduleSerializer(serializers.Serializer):
|
||||||
|
scheduled_datetime = serializers.DateTimeField()
|
||||||
|
scheduled_duration = serializers.IntegerField(default=60, min_value=30, max_value=240)
|
||||||
|
|
||||||
|
def validate_scheduled_datetime(self, value):
|
||||||
|
if value <= timezone.now():
|
||||||
|
raise serializers.ValidationError("Scheduled datetime must be in the future.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
def validate_scheduled_duration(self, value):
|
||||||
|
if value < 30:
|
||||||
|
raise serializers.ValidationError("Duration must be at least 30 minutes.")
|
||||||
|
if value > 240:
|
||||||
|
raise serializers.ValidationError("Duration cannot exceed 4 hours.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
class AppointmentRejectSerializer(serializers.Serializer):
|
||||||
|
rejection_reason = serializers.CharField(required=False, allow_blank=True)
|
||||||
273
meetings/tasks.py
Normal file
273
meetings/tasks.py
Normal file
@ -0,0 +1,273 @@
|
|||||||
|
from django.core.mail import send_mail
|
||||||
|
from django.conf import settings
|
||||||
|
from django.template.loader import render_to_string
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
def send_booking_notification_email(booking_id):
|
||||||
|
"""
|
||||||
|
Send email to admin when a new therapy booking is submitted
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from .models import TherapyBooking
|
||||||
|
booking = TherapyBooking.objects.get(id=booking_id)
|
||||||
|
|
||||||
|
subject = f"New Therapy Booking Request - {booking.full_name}"
|
||||||
|
|
||||||
|
html_message = render_to_string('emails/booking_notification.html', {
|
||||||
|
'booking': booking,
|
||||||
|
})
|
||||||
|
|
||||||
|
plain_message = f"""
|
||||||
|
New Therapy Booking Request Received!
|
||||||
|
|
||||||
|
Client: {booking.full_name}
|
||||||
|
Email: {booking.email}
|
||||||
|
Phone: {booking.phone}
|
||||||
|
Appointment Type: {booking.get_appointment_type_display()}
|
||||||
|
Preferred Date: {booking.preferred_date}
|
||||||
|
Preferred Time: {booking.preferred_time}
|
||||||
|
|
||||||
|
Additional Message:
|
||||||
|
{booking.additional_message or 'No additional message provided.'}
|
||||||
|
|
||||||
|
Please review this booking in the admin dashboard.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Send to admin email
|
||||||
|
admin_email = settings.ADMIN_EMAIL or settings.DEFAULT_FROM_EMAIL
|
||||||
|
|
||||||
|
send_mail(
|
||||||
|
subject=subject,
|
||||||
|
message=plain_message,
|
||||||
|
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||||
|
recipient_list=[admin_email],
|
||||||
|
html_message=html_message,
|
||||||
|
fail_silently=True, # Don't crash if email fails
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Booking notification email sent for booking {booking_id}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send booking notification email: {str(e)}")
|
||||||
|
|
||||||
|
def send_booking_confirmation_email(booking_id):
|
||||||
|
"""
|
||||||
|
Send beautiful confirmation email when booking is confirmed
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from .models import TherapyBooking
|
||||||
|
booking = TherapyBooking.objects.get(id=booking_id)
|
||||||
|
|
||||||
|
logger.info(f"Attempting to send confirmation email for booking {booking_id} to {booking.email}")
|
||||||
|
|
||||||
|
subject = f"✅ Appointment Confirmed - {booking.get_appointment_type_display()} - {booking.confirmed_datetime.strftime('%b %d')}"
|
||||||
|
|
||||||
|
# Render professional HTML template
|
||||||
|
html_message = render_to_string('emails/booking_confirmed.html', {
|
||||||
|
'booking': booking,
|
||||||
|
'payment_url': f"https://attunehearttherapy.com/payment/{booking.id}",
|
||||||
|
})
|
||||||
|
|
||||||
|
# Get appointment duration
|
||||||
|
duration = get_appointment_duration(booking.appointment_type)
|
||||||
|
|
||||||
|
# Format datetime for plain text
|
||||||
|
formatted_datetime = booking.confirmed_datetime.strftime('%A, %B %d, %Y at %I:%M %p')
|
||||||
|
|
||||||
|
# Build plain text message dynamically
|
||||||
|
plain_message_parts = [
|
||||||
|
f"APPOINTMENT CONFIRMED - Attune Heart Therapy",
|
||||||
|
f"",
|
||||||
|
f"Dear {booking.full_name},",
|
||||||
|
f"",
|
||||||
|
f"We're delighted to confirm your {booking.get_appointment_type_display()} appointment.",
|
||||||
|
f"",
|
||||||
|
f"APPOINTMENT DETAILS:",
|
||||||
|
f"- Type: {booking.get_appointment_type_display()}",
|
||||||
|
f"- Date & Time: {formatted_datetime}",
|
||||||
|
f"- Duration: {duration}",
|
||||||
|
f"- Therapist: {booking.assigned_therapist.get_full_name() if booking.assigned_therapist else 'To be assigned'}",
|
||||||
|
f"- Payment Status: {booking.get_payment_status_display()}",
|
||||||
|
f"",
|
||||||
|
f"JOIN YOUR SESSION:",
|
||||||
|
f"Video Meeting Link: {booking.jitsi_meet_url}",
|
||||||
|
f"",
|
||||||
|
f"Please join 5-10 minutes before your scheduled time to test your audio and video.",
|
||||||
|
f"",
|
||||||
|
f"PREPARATION TIPS:",
|
||||||
|
f"• Test your camera, microphone, and internet connection",
|
||||||
|
f"• Find a quiet, private space",
|
||||||
|
f"• Use Chrome, Firefox, or Safari for best experience",
|
||||||
|
f"• Have a glass of water nearby",
|
||||||
|
f""
|
||||||
|
]
|
||||||
|
|
||||||
|
# Add payment information if not paid
|
||||||
|
if booking.payment_status != 'paid':
|
||||||
|
plain_message_parts.extend([
|
||||||
|
f"PAYMENT INFORMATION:",
|
||||||
|
f"Your session fee of ${booking.amount} is pending. Please complete your payment before the session.",
|
||||||
|
f""
|
||||||
|
])
|
||||||
|
|
||||||
|
plain_message_parts.extend([
|
||||||
|
f"NEED TO RESCHEDULE?",
|
||||||
|
f"Please contact us at least 24 hours in advance at (954) 807-3027.",
|
||||||
|
f"",
|
||||||
|
f"We look forward to supporting you on your healing journey!",
|
||||||
|
f"",
|
||||||
|
f"Warm regards,",
|
||||||
|
f"The Attune Heart Therapy Team",
|
||||||
|
f"",
|
||||||
|
f"Contact Information:",
|
||||||
|
f"📞 (954) 807-3027",
|
||||||
|
f"✉️ hello@attunehearttherapy.com",
|
||||||
|
f"🌐 attunehearttherapy.com",
|
||||||
|
f"",
|
||||||
|
f"Confirmation ID: {booking.id}"
|
||||||
|
])
|
||||||
|
|
||||||
|
plain_message = "\n".join(plain_message_parts)
|
||||||
|
|
||||||
|
result = send_mail(
|
||||||
|
subject=subject,
|
||||||
|
message=plain_message,
|
||||||
|
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||||
|
recipient_list=[booking.email],
|
||||||
|
html_message=html_message,
|
||||||
|
fail_silently=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"✅ Booking confirmation email sent successfully to {booking.email}. Sendmail result: {result}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Failed to send booking confirmation email: {str(e)}", exc_info=True)
|
||||||
|
return False
|
||||||
|
def get_appointment_duration(appointment_type):
|
||||||
|
"""Helper function to get appointment duration"""
|
||||||
|
durations = {
|
||||||
|
'initial-consultation': '90 minutes',
|
||||||
|
'individual-therapy': '60 minutes',
|
||||||
|
'family-therapy': '90 minutes',
|
||||||
|
'couples-therapy': '75 minutes',
|
||||||
|
'group-therapy': '90 minutes',
|
||||||
|
'follow-up': '45 minutes',
|
||||||
|
}
|
||||||
|
return durations.get(appointment_type, '60 minutes')
|
||||||
|
def get_appointment_duration(appointment_type):
|
||||||
|
"""Helper function to get appointment duration"""
|
||||||
|
durations = {
|
||||||
|
'initial-consultation': '90 minutes',
|
||||||
|
'individual-therapy': '60 minutes',
|
||||||
|
'family-therapy': '90 minutes',
|
||||||
|
'couples-therapy': '75 minutes',
|
||||||
|
'group-therapy': '90 minutes',
|
||||||
|
'follow-up': '45 minutes',
|
||||||
|
}
|
||||||
|
return durations.get(appointment_type, '60 minutes')
|
||||||
|
|
||||||
|
def send_payment_confirmation_email(booking_id):
|
||||||
|
try:
|
||||||
|
from .models import TherapyBooking
|
||||||
|
booking = TherapyBooking.objects.get(id=booking_id)
|
||||||
|
|
||||||
|
subject = f"💳 Payment Confirmed - {booking.get_appointment_type_display()}"
|
||||||
|
|
||||||
|
html_message = render_to_string('emails/payment_confirmed.html', {
|
||||||
|
'booking': booking,
|
||||||
|
})
|
||||||
|
|
||||||
|
duration = get_appointment_duration(booking.appointment_type)
|
||||||
|
payment_id = booking.stripe_payment_intent_id or str(booking.id)
|
||||||
|
|
||||||
|
plain_message = f"""
|
||||||
|
PAYMENT CONFIRMED - Attune Heart Therapy
|
||||||
|
|
||||||
|
Dear {booking.full_name},
|
||||||
|
|
||||||
|
Thank you for your payment! Your {booking.get_appointment_type_display()} appointment is now fully confirmed.
|
||||||
|
|
||||||
|
PAYMENT DETAILS:
|
||||||
|
- Amount Paid: ${booking.amount}
|
||||||
|
- Payment Date: {booking.paid_at.strftime('%B %d, %Y at %I:%M %p')}
|
||||||
|
- Payment ID: {payment_id}
|
||||||
|
- Appointment: {booking.get_appointment_type_display()}
|
||||||
|
|
||||||
|
SESSION DETAILS:
|
||||||
|
- Date & Time: {booking.confirmed_datetime.strftime('%A, %B %d, %Y at %I:%M %p')}
|
||||||
|
- Duration: {duration}
|
||||||
|
- Therapist: {booking.assigned_therapist.get_full_name() if booking.assigned_therapist else 'To be assigned'}
|
||||||
|
- Video Meeting: {booking.jitsi_meet_url}
|
||||||
|
|
||||||
|
Please join 5-10 minutes before your scheduled time to test your audio and video.
|
||||||
|
|
||||||
|
This email serves as your receipt for tax purposes.
|
||||||
|
|
||||||
|
If you have any questions about your payment or appointment, please contact us at (954) 807-3027.
|
||||||
|
|
||||||
|
Thank you for trusting us with your care!
|
||||||
|
|
||||||
|
Warm regards,
|
||||||
|
The Attune Heart Therapy Team
|
||||||
|
|
||||||
|
Contact Information:
|
||||||
|
📞 (954) 807-3027
|
||||||
|
✉️ hello@attunehearttherapy.com
|
||||||
|
🌐 attunehearttherapy.com
|
||||||
|
|
||||||
|
Payment ID: {payment_id}
|
||||||
|
Processed: {booking.paid_at.strftime('%Y-%m-%d %H:%M')}
|
||||||
|
"""
|
||||||
|
|
||||||
|
send_mail(
|
||||||
|
subject=subject,
|
||||||
|
message=plain_message,
|
||||||
|
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||||
|
recipient_list=[booking.email],
|
||||||
|
html_message=html_message,
|
||||||
|
fail_silently=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"✅ Payment confirmation email sent to {booking.email}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Failed to send payment confirmation email: {str(e)}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def send_payment_failure_email(booking_id):
|
||||||
|
"""
|
||||||
|
Send payment failure email
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from .models import TherapyBooking
|
||||||
|
booking = TherapyBooking.objects.get(id=booking_id)
|
||||||
|
|
||||||
|
subject = f"Payment Issue - {booking.get_appointment_type_display()}"
|
||||||
|
|
||||||
|
plain_message = f"""
|
||||||
|
Payment Issue
|
||||||
|
|
||||||
|
Dear {booking.full_name},
|
||||||
|
|
||||||
|
We encountered an issue processing your payment for the {booking.get_appointment_type_display()} appointment.
|
||||||
|
|
||||||
|
Please try again or contact us at (954) 807-3027 to complete your payment.
|
||||||
|
|
||||||
|
Best regards,
|
||||||
|
Attune Heart Therapy Team
|
||||||
|
"""
|
||||||
|
|
||||||
|
send_mail(
|
||||||
|
subject=subject,
|
||||||
|
message=plain_message,
|
||||||
|
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||||
|
recipient_list=[booking.email],
|
||||||
|
fail_silently=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send payment failure email: {str(e)}")
|
||||||
3
meetings/tests.py
Normal file
3
meetings/tests.py
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
# Create your tests here.
|
||||||
28
meetings/urls.py
Normal file
28
meetings/urls.py
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
from django.urls import path
|
||||||
|
from .views import (
|
||||||
|
AdminAvailabilityView,
|
||||||
|
AppointmentRequestListView,
|
||||||
|
AppointmentRequestCreateView,
|
||||||
|
AppointmentRequestDetailView,
|
||||||
|
schedule_appointment,
|
||||||
|
reject_appointment,
|
||||||
|
available_dates,
|
||||||
|
user_appointments,
|
||||||
|
appointment_stats
|
||||||
|
)
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('admin/availability/', AdminAvailabilityView.as_view(), name='admin-availability'),
|
||||||
|
|
||||||
|
path('appointments/', AppointmentRequestListView.as_view(), name='appointment-list'),
|
||||||
|
path('appointments/create/', AppointmentRequestCreateView.as_view(), name='appointment-create'),
|
||||||
|
path('appointments/<uuid:pk>/', AppointmentRequestDetailView.as_view(), name='appointment-detail'),
|
||||||
|
|
||||||
|
path('appointments/<uuid:pk>/schedule/', schedule_appointment, name='appointment-schedule'),
|
||||||
|
path('appointments/<uuid:pk>/reject/', reject_appointment, name='appointment-reject'),
|
||||||
|
|
||||||
|
path('appointments/available-dates/', available_dates, name='available-dates'),
|
||||||
|
path('user/appointments/', user_appointments, name='user-appointments'),
|
||||||
|
|
||||||
|
path('appointments/stats/', appointment_stats, name='appointment-stats'),
|
||||||
|
]
|
||||||
168
meetings/views.py
Normal file
168
meetings/views.py
Normal file
@ -0,0 +1,168 @@
|
|||||||
|
from rest_framework import generics, status
|
||||||
|
from rest_framework.decorators import api_view, permission_classes
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.permissions import IsAuthenticated, AllowAny
|
||||||
|
from django.utils import timezone
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from .models import AdminWeeklyAvailability, AppointmentRequest
|
||||||
|
from .serializers import (
|
||||||
|
AdminWeeklyAvailabilitySerializer,
|
||||||
|
AppointmentRequestSerializer,
|
||||||
|
AppointmentRequestCreateSerializer,
|
||||||
|
AppointmentScheduleSerializer,
|
||||||
|
AppointmentRejectSerializer
|
||||||
|
)
|
||||||
|
from .email_service import EmailService
|
||||||
|
|
||||||
|
class AdminAvailabilityView(generics.RetrieveUpdateAPIView):
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
serializer_class = AdminWeeklyAvailabilitySerializer
|
||||||
|
|
||||||
|
def get_object(self):
|
||||||
|
obj, created = AdminWeeklyAvailability.objects.get_or_create(
|
||||||
|
defaults={'available_days': []}
|
||||||
|
)
|
||||||
|
return obj
|
||||||
|
|
||||||
|
class AppointmentRequestListView(generics.ListAPIView):
|
||||||
|
serializer_class = AppointmentRequestSerializer
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
queryset = AppointmentRequest.objects.all()
|
||||||
|
|
||||||
|
if self.request.user.is_staff:
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
return queryset.filter(email=self.request.user.email)
|
||||||
|
|
||||||
|
class AppointmentRequestCreateView(generics.CreateAPIView):
|
||||||
|
permission_classes = [AllowAny]
|
||||||
|
queryset = AppointmentRequest.objects.all()
|
||||||
|
serializer_class = AppointmentRequestCreateSerializer
|
||||||
|
|
||||||
|
def perform_create(self, serializer):
|
||||||
|
availability = AdminWeeklyAvailability.objects.first()
|
||||||
|
if availability:
|
||||||
|
available_days = availability.available_days
|
||||||
|
preferred_dates = serializer.validated_data['preferred_dates']
|
||||||
|
|
||||||
|
for date_str in preferred_dates:
|
||||||
|
date_obj = datetime.strptime(date_str, '%Y-%m-%d').date()
|
||||||
|
if date_obj.weekday() not in available_days:
|
||||||
|
from rest_framework.exceptions import ValidationError
|
||||||
|
raise ValidationError(f'Date {date_str} is not available for appointments.')
|
||||||
|
|
||||||
|
appointment = serializer.save()
|
||||||
|
EmailService.send_admin_notification(appointment)
|
||||||
|
|
||||||
|
class AppointmentRequestDetailView(generics.RetrieveAPIView):
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
queryset = AppointmentRequest.objects.all()
|
||||||
|
serializer_class = AppointmentRequestSerializer
|
||||||
|
lookup_field = 'pk'
|
||||||
|
|
||||||
|
@api_view(['POST'])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
def schedule_appointment(request, pk):
|
||||||
|
try:
|
||||||
|
appointment = AppointmentRequest.objects.get(pk=pk)
|
||||||
|
except AppointmentRequest.DoesNotExist:
|
||||||
|
return Response({'error': 'Appointment not found'}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
if appointment.status != 'pending_review':
|
||||||
|
return Response(
|
||||||
|
{'error': 'Only pending review appointments can be scheduled.'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
serializer = AppointmentScheduleSerializer(data=request.data)
|
||||||
|
if serializer.is_valid():
|
||||||
|
appointment.schedule_appointment(
|
||||||
|
datetime_obj=serializer.validated_data['scheduled_datetime'],
|
||||||
|
duration=serializer.validated_data['scheduled_duration']
|
||||||
|
)
|
||||||
|
|
||||||
|
EmailService.send_appointment_scheduled(appointment)
|
||||||
|
|
||||||
|
response_serializer = AppointmentRequestSerializer(appointment)
|
||||||
|
return Response({
|
||||||
|
**response_serializer.data,
|
||||||
|
'message': 'Appointment scheduled successfully. Jitsi meeting created.',
|
||||||
|
'jitsi_meeting_created': appointment.has_jitsi_meeting
|
||||||
|
})
|
||||||
|
|
||||||
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['POST'])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
def reject_appointment(request, pk):
|
||||||
|
try:
|
||||||
|
appointment = AppointmentRequest.objects.get(pk=pk)
|
||||||
|
except AppointmentRequest.DoesNotExist:
|
||||||
|
return Response({'error': 'Appointment not found'}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
if appointment.status != 'pending_review':
|
||||||
|
return Response(
|
||||||
|
{'error': 'Only pending appointments can be rejected'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
serializer = AppointmentRejectSerializer(data=request.data)
|
||||||
|
if serializer.is_valid():
|
||||||
|
appointment.reject_appointment(serializer.validated_data.get('rejection_reason', ''))
|
||||||
|
EmailService.send_appointment_rejected(appointment)
|
||||||
|
return Response(AppointmentRequestSerializer(appointment).data)
|
||||||
|
|
||||||
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
@api_view(['GET'])
|
||||||
|
@permission_classes([AllowAny])
|
||||||
|
def available_dates(request):
|
||||||
|
availability = AdminWeeklyAvailability.objects.first()
|
||||||
|
if not availability:
|
||||||
|
return Response([])
|
||||||
|
|
||||||
|
available_days = availability.available_days
|
||||||
|
today = timezone.now().date()
|
||||||
|
available_dates = []
|
||||||
|
|
||||||
|
for i in range(1, 31):
|
||||||
|
date = today + timedelta(days=i)
|
||||||
|
if date.weekday() in available_days:
|
||||||
|
available_dates.append(date.strftime('%Y-%m-%d'))
|
||||||
|
|
||||||
|
return Response(available_dates)
|
||||||
|
|
||||||
|
@api_view(['GET'])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
def user_appointments(request):
|
||||||
|
appointments = AppointmentRequest.objects.filter(
|
||||||
|
email=request.user.email
|
||||||
|
).order_by('-created_at')
|
||||||
|
|
||||||
|
serializer = AppointmentRequestSerializer(appointments, many=True)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
@api_view(['GET'])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
def appointment_stats(request):
|
||||||
|
if not request.user.is_staff:
|
||||||
|
return Response(
|
||||||
|
{'error': 'Unauthorized'},
|
||||||
|
status=status.HTTP_403_FORBIDDEN
|
||||||
|
)
|
||||||
|
|
||||||
|
total = AppointmentRequest.objects.count()
|
||||||
|
pending = AppointmentRequest.objects.filter(status='pending_review').count()
|
||||||
|
scheduled = AppointmentRequest.objects.filter(status='scheduled').count()
|
||||||
|
rejected = AppointmentRequest.objects.filter(status='rejected').count()
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'total_requests': total,
|
||||||
|
'pending_review': pending,
|
||||||
|
'scheduled': scheduled,
|
||||||
|
'rejected': rejected,
|
||||||
|
'completion_rate': round((scheduled / total * 100), 2) if total > 0 else 0
|
||||||
|
})
|
||||||
@ -1,100 +0,0 @@
|
|||||||
{% extends "emails/base.html" %}
|
|
||||||
|
|
||||||
{% block title %}New Therapy Booking Request - Action Required{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="email-header" style="background: linear-gradient(135deg, #dc2626, #ea580c);">
|
|
||||||
<h1>📋 NEW THERAPY BOOKING REQUEST</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="email-body">
|
|
||||||
<div class="urgent-badge">⏰ ACTION REQUIRED - Please respond within 24 hours</div>
|
|
||||||
|
|
||||||
<div class="section">
|
|
||||||
<h2 class="section-title">Patient Information</h2>
|
|
||||||
<div class="info-card">
|
|
||||||
<div class="info-item">
|
|
||||||
<span class="info-label">Full Name:</span>
|
|
||||||
<span class="info-value">{{ booking.full_name }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="info-item">
|
|
||||||
<span class="info-label">Email:</span>
|
|
||||||
<span class="info-value">{{ booking.email }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="info-item">
|
|
||||||
<span class="info-label">Phone:</span>
|
|
||||||
<span class="info-value">{{ booking.phone }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="info-item">
|
|
||||||
<span class="info-label">Submitted:</span>
|
|
||||||
<span class="info-value">{{ booking.created_at|date:"F d, Y" }} at {{ booking.created_at|time:"g:i A" }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="section">
|
|
||||||
<h2 class="section-title">Appointment Details</h2>
|
|
||||||
<div class="info-card">
|
|
||||||
<div class="info-item">
|
|
||||||
<span class="info-label">Appointment Type:</span>
|
|
||||||
<span class="info-value">{{ booking.get_appointment_type_display }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="info-item">
|
|
||||||
<span class="info-label">Preferred Date:</span>
|
|
||||||
<span class="info-value">{{ booking.preferred_date|date:"l, F d, Y" }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="info-item">
|
|
||||||
<span class="info-label">Preferred Time:</span>
|
|
||||||
<span class="info-value">{{ booking.preferred_time }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="info-item">
|
|
||||||
<span class="info-label">Session Fee:</span>
|
|
||||||
<span class="info-value">${{ booking.amount }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if booking.additional_message %}
|
|
||||||
<div class="section">
|
|
||||||
<h2 class="section-title">Patient's Message</h2>
|
|
||||||
<div class="info-card">
|
|
||||||
<div style="background: white; padding: 16px; border-radius: 6px; border-left: 4px solid #10b981;">
|
|
||||||
{{ booking.additional_message }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div class="section">
|
|
||||||
<h2 class="section-title">Required Actions</h2>
|
|
||||||
<div class="steps">
|
|
||||||
<div class="step">
|
|
||||||
<strong>Review Patient Information</strong><br>
|
|
||||||
Assess clinical appropriateness and availability.
|
|
||||||
</div>
|
|
||||||
<div class="step">
|
|
||||||
<strong>Contact Patient</strong><br>
|
|
||||||
Reach out within 24 hours to confirm appointment details.
|
|
||||||
</div>
|
|
||||||
<div class="step">
|
|
||||||
<strong>Confirm Booking</strong><br>
|
|
||||||
Update booking status and send confirmation email.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="text-align: center; margin: 30px 0;">
|
|
||||||
<a href="https://attunehearttherapy.com/admin" class="button" style="background: linear-gradient(135deg, #dc2626, #ea580c);">
|
|
||||||
📊 Manage This Booking
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="footer">
|
|
||||||
<p><strong>Attune Heart Therapy - Admin Portal</strong></p>
|
|
||||||
<div class="contact-info">
|
|
||||||
Booking ID: {{ booking.id }}<br>
|
|
||||||
Received: {{ booking.created_at|date:"Y-m-d H:i" }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
304
templates/emails/admin_notification.html
Normal file
304
templates/emails/admin_notification.html
Normal file
@ -0,0 +1,304 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>New Appointment Request - Action Required</title>
|
||||||
|
<style>
|
||||||
|
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap");
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI",
|
||||||
|
Roboto, sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #333;
|
||||||
|
background-color: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.email-container {
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1),
|
||||||
|
0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.email-header {
|
||||||
|
background-image: linear-gradient(to right, #e11d48, #db2777, #f97316);
|
||||||
|
padding: 40px 30px;
|
||||||
|
text-align: center;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.email-header h1 {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.email-header p {
|
||||||
|
font-size: 16px;
|
||||||
|
opacity: 0.9;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.email-body {
|
||||||
|
padding: 40px 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.urgent-badge {
|
||||||
|
background: linear-gradient(135deg, #dc2626, #ea580c);
|
||||||
|
color: white;
|
||||||
|
padding: 12px 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2937;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
border-bottom: 2px solid #f1f5f9;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-card {
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-label {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-value {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1e293b;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-container {
|
||||||
|
background: white;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border-left: 4px solid #10b981;
|
||||||
|
font-style: italic;
|
||||||
|
color: #475569;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preferences {
|
||||||
|
background: #fffaf0;
|
||||||
|
border-left: 4px solid #ed8936;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 0 8px 8px 0;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pref-title {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #744210;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-section {
|
||||||
|
text-align: center;
|
||||||
|
margin: 35px 0 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
display: inline-block;
|
||||||
|
background-image: linear-gradient(to right, #e11d48, #db2777, #f97316);
|
||||||
|
color: white;
|
||||||
|
padding: 16px 32px;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 16px;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-note {
|
||||||
|
background: #fff5f5;
|
||||||
|
border: 1px solid #fed7d7;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 15px;
|
||||||
|
margin-top: 20px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #c53030;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.email-footer {
|
||||||
|
background-image: linear-gradient(to right, #e11d48, #db2777, #f97316);
|
||||||
|
padding: 30px;
|
||||||
|
text-align: center;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-text {
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copyright {
|
||||||
|
font-size: 12px;
|
||||||
|
margin-top: 15px;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.email-container {
|
||||||
|
margin: 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.email-header,
|
||||||
|
.email-body,
|
||||||
|
.email-footer {
|
||||||
|
padding: 25px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.urgent-badge {
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="email-container">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="email-header">
|
||||||
|
<h1>New Appointment Request</h1>
|
||||||
|
<p>A client has requested to schedule a therapy session</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Body -->
|
||||||
|
<div class="email-body">
|
||||||
|
<div class="urgent-badge">
|
||||||
|
ACTION REQUIRED - Please respond within 24 hours
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Client Information Section -->
|
||||||
|
<div class="section">
|
||||||
|
<h2 class="section-title">Client Information</h2>
|
||||||
|
<div class="info-card">
|
||||||
|
<div class="info-grid">
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">Full Name</span>
|
||||||
|
<span class="info-value">{{ appointment.full_name }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">Email Address</span>
|
||||||
|
<span class="info-value">{{ appointment.email }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">Phone Number</span>
|
||||||
|
<span class="info-value"
|
||||||
|
>{{ appointment.phone|default:"Not provided" }}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">Request Date</span>
|
||||||
|
<span class="info-value"
|
||||||
|
>{{ appointment.formatted_created_at }}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Client Message Section -->
|
||||||
|
{% if appointment.reason %}
|
||||||
|
<div class="section">
|
||||||
|
<h2 class="section-title">Client's Message</h2>
|
||||||
|
<div class="message-container">"{{ appointment.reason }}"</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Preferences Section -->
|
||||||
|
<div class="preferences">
|
||||||
|
<div class="pref-title">Preferred Availability</div>
|
||||||
|
<div style="margin-bottom: 10px">
|
||||||
|
<strong>Dates:</strong> {{ preferred_dates }}
|
||||||
|
</div>
|
||||||
|
<div><strong>Time Slots:</strong> {{ preferred_times }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action Button -->
|
||||||
|
<div class="action-section">
|
||||||
|
<a href="{{ admin_dashboard_url }}" class="button">
|
||||||
|
Review Appointment Request
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Alert Note -->
|
||||||
|
<div class="alert-note">
|
||||||
|
Please respond to this request within 24 hours to ensure the best
|
||||||
|
client experience.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div class="email-footer">
|
||||||
|
<div class="company-name">{{ settings.SITE_NAME|default:"Attune Heart Therapy" }}</div>
|
||||||
|
<p class="support-info">
|
||||||
|
Need help? Contact our support team at
|
||||||
|
<a
|
||||||
|
href="mailto:hello@attunehearttherapy.com"
|
||||||
|
style="color: #fff; text-decoration: none"
|
||||||
|
>hello@attunehearttherapy.com</a
|
||||||
|
>
|
||||||
|
</p>
|
||||||
|
<p class="copyright">
|
||||||
|
© {% now "Y" %} {{ settings.SITE_NAME|default:"Attune Heart Therapy" }}.
|
||||||
|
All rights reserved.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
316
templates/emails/appointment_rejected.html
Normal file
316
templates/emails/appointment_rejected.html
Normal file
@ -0,0 +1,316 @@
|
|||||||
|
<!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>
|
||||||
|
|
||||||
|
<!-- Action Button -->
|
||||||
|
<div class="action-section">
|
||||||
|
<a href="{{ user_dashboard_url }}" class="button">
|
||||||
|
Submit New Request
|
||||||
|
</a>
|
||||||
|
</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>
|
||||||
320
templates/emails/appointment_scheduled.html
Normal file
320
templates/emails/appointment_scheduled.html
Normal file
@ -0,0 +1,320 @@
|
|||||||
|
<!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 class="">
|
||||||
|
<div class="">
|
||||||
|
<div class="detail-icon">⏰</div>
|
||||||
|
<div class="detail-label">Duration</div>
|
||||||
|
<div class="detail-value">
|
||||||
|
{{ appointment.meeting_duration_display }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="">
|
||||||
|
<div class="detail-label">Session Type</div>
|
||||||
|
<div class="detail-value">Virtual Meeting</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Preparation Tips -->
|
||||||
|
<div class="">
|
||||||
|
<div class="">📋 To Prepare for Your Session</div>
|
||||||
|
<ul class="">
|
||||||
|
<li>Find a quiet, comfortable space for our session</li>
|
||||||
|
<li>Test your internet connection and audio/video</li>
|
||||||
|
<li>Have any notes or questions ready</li>
|
||||||
|
<li>Join the meeting 5 minutes early to get settled</li>
|
||||||
|
<li>Ensure you're in a private, distraction-free environment</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action Button -->
|
||||||
|
<div class="action-section">
|
||||||
|
<a href="{{ user_dashboard_url }}" class="button">
|
||||||
|
View in Dashboard
|
||||||
|
</a>
|
||||||
|
</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>
|
||||||
@ -1,145 +0,0 @@
|
|||||||
{% extends "emails/base.html" %}
|
|
||||||
|
|
||||||
{% block title %}Appointment Confirmed - Attune Heart Therapy{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="email-header">
|
|
||||||
<h1>✅ Your Appointment is Confirmed!</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="email-body">
|
|
||||||
<div class="greeting">
|
|
||||||
Dear <strong>{{ booking.full_name }}</strong>,
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="section">
|
|
||||||
<p>We're delighted to confirm your <strong>{{ booking.get_appointment_type_display }}</strong> appointment. Your healing journey begins now, and we're honored to walk this path with you.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="section">
|
|
||||||
<h2 class="section-title">Appointment Details</h2>
|
|
||||||
<div class="info-card">
|
|
||||||
<div class="info-item">
|
|
||||||
<span class="info-label">Appointment Type:</span>
|
|
||||||
<span class="info-value">{{ booking.get_appointment_type_display }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="info-item">
|
|
||||||
<span class="info-label">Date & Time:</span>
|
|
||||||
<span class="info-value">{{ booking.confirmed_datetime|date:"l, F d, Y" }} at {{ booking.confirmed_datetime|time:"g:i A" }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="info-item">
|
|
||||||
<span class="info-label">Duration:</span>
|
|
||||||
<span class="info-value">
|
|
||||||
{% if booking.appointment_type == 'initial-consultation' %}90 minutes
|
|
||||||
{% elif booking.appointment_type == 'individual-therapy' %}60 minutes
|
|
||||||
{% elif booking.appointment_type == 'family-therapy' %}90 minutes
|
|
||||||
{% elif booking.appointment_type == 'couples-therapy' %}75 minutes
|
|
||||||
{% elif booking.appointment_type == 'group-therapy' %}90 minutes
|
|
||||||
{% elif booking.appointment_type == 'follow-up' %}45 minutes
|
|
||||||
{% else %}60 minutes{% endif %}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="info-item">
|
|
||||||
<span class="info-label">Therapist:</span>
|
|
||||||
<span class="info-value">{{ booking.assigned_therapist.get_full_name }}</span>
|
|
||||||
</div>
|
|
||||||
{% if booking.payment_status == 'paid' %}
|
|
||||||
<div class="info-item">
|
|
||||||
<span class="info-label">Payment Status:</span>
|
|
||||||
<span class="info-value" style="color: #10b981; font-weight: 600;">✅ Paid</span>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="section">
|
|
||||||
<h2 class="section-title">Join Your Session</h2>
|
|
||||||
<div class="info-card" style="background: linear-gradient(135deg, #f0f9ff, #e0f2fe); border-left-color: #0ea5e9;">
|
|
||||||
<div style="text-align: center; padding: 10px 0;">
|
|
||||||
<div style="font-size: 16px; font-weight: 600; color: #0369a1; margin-bottom: 15px;">
|
|
||||||
📅 Secure Video Session
|
|
||||||
</div>
|
|
||||||
<a href="{{ booking.jitsi_meet_url }}" class="button" style="font-size: 16px; padding: 16px 40px;">
|
|
||||||
🎥 Join Video Session
|
|
||||||
</a>
|
|
||||||
<div style="margin-top: 15px; font-size: 14px; color: #64748b;">
|
|
||||||
Or copy this link:<br>
|
|
||||||
<span style="word-break: break-all; color: #0ea5e9;">{{ booking.jitsi_meet_url }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="section">
|
|
||||||
<h2 class="section-title">Preparing for Your Session</h2>
|
|
||||||
<div class="steps">
|
|
||||||
<div class="step">
|
|
||||||
<strong>Test Your Technology</strong><br>
|
|
||||||
Please test your camera, microphone, and internet connection before the session.
|
|
||||||
</div>
|
|
||||||
<div class="step">
|
|
||||||
<strong>Find a Quiet Space</strong><br>
|
|
||||||
Choose a private, comfortable location where you won't be interrupted.
|
|
||||||
</div>
|
|
||||||
<div class="step">
|
|
||||||
<strong>Join Early</strong><br>
|
|
||||||
Please join 5-10 minutes before your scheduled time to ensure everything is working.
|
|
||||||
</div>
|
|
||||||
<div class="step">
|
|
||||||
<strong>Browser Recommendation</strong><br>
|
|
||||||
Use Chrome, Firefox, or Safari for the best video experience.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if booking.payment_status != 'paid' %}
|
|
||||||
<div class="section">
|
|
||||||
<h2 class="section-title">Payment Information</h2>
|
|
||||||
<div class="info-card" style="background: #fffbeb; border-left-color: #f59e0b;">
|
|
||||||
<div style="text-align: center;">
|
|
||||||
<div style="color: #d97706; font-weight: 600; margin-bottom: 10px;">
|
|
||||||
💳 Payment Required
|
|
||||||
</div>
|
|
||||||
<p>Your session fee of <strong>${{ booking.amount }}</strong> is pending. Please complete your payment before the session.</p>
|
|
||||||
<a href="https://attunehearttherapy.com/payment/{{ booking.id }}" class="button" style="background: linear-gradient(135deg, #f59e0b, #d97706);">
|
|
||||||
Complete Payment
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div class="section">
|
|
||||||
<h2 class="section-title">Need to Reschedule?</h2>
|
|
||||||
<p>If you need to reschedule or cancel your appointment, please contact us at least 24 hours in advance:</p>
|
|
||||||
<div style="text-align: center; margin: 20px 0;">
|
|
||||||
<a href="tel:+19548073027" class="button" style="background: linear-gradient(135deg, #64748b, #475569);">
|
|
||||||
📞 Call (954) 807-3027
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="section">
|
|
||||||
<div style="background: #f0fdf4; padding: 20px; border-radius: 8px; border-left: 4px solid #10b981;">
|
|
||||||
<p style="color: #065f46; font-style: italic; margin: 0;">
|
|
||||||
"The privilege of a lifetime is to become who you truly are."<br>
|
|
||||||
<span style="font-size: 14px;">- Carl Jung</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="footer">
|
|
||||||
<p><strong>Attune Heart Therapy</strong></p>
|
|
||||||
<p>Compassionate Care for Your Healing Journey</p>
|
|
||||||
<div class="contact-info">
|
|
||||||
📞 (954) 807-3027<br>
|
|
||||||
✉️ hello@attunehearttherapy.com<br>
|
|
||||||
🌐 attunehearttherapy.com
|
|
||||||
</div>
|
|
||||||
<p style="font-size: 12px; color: #9ca3af; margin-top: 15px;">
|
|
||||||
Confirmation ID: {{ booking.id }}<br>
|
|
||||||
Sent: {{ booking.confirmed_datetime|date:"Y-m-d H:i" }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
@ -1,86 +0,0 @@
|
|||||||
{% extends "emails/base.html" %}
|
|
||||||
|
|
||||||
{% block title %}Booking Request Received - Attune Heart Therapy{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="email-header">
|
|
||||||
<h1>🎉 Thank You for Your Booking Request!</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="email-body">
|
|
||||||
<div class="greeting">
|
|
||||||
Dear <strong>{{ booking.full_name }}</strong>,
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="section">
|
|
||||||
<p>We have received your request for a <strong>{{ booking.get_appointment_type_display }}</strong> appointment and we're excited to support you on your healing journey.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="section">
|
|
||||||
<h2 class="section-title">Your Request Details</h2>
|
|
||||||
<div class="info-card">
|
|
||||||
<div class="info-item">
|
|
||||||
<span class="info-label">Appointment Type:</span>
|
|
||||||
<span class="info-value">{{ booking.get_appointment_type_display }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="info-item">
|
|
||||||
<span class="info-label">Preferred Date:</span>
|
|
||||||
<span class="info-value">{{ booking.preferred_date|date:"l, F d, Y" }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="info-item">
|
|
||||||
<span class="info-label">Preferred Time:</span>
|
|
||||||
<span class="info-value">{{ booking.preferred_time }}</span>
|
|
||||||
</div>
|
|
||||||
{% if booking.additional_message %}
|
|
||||||
<div class="info-item">
|
|
||||||
<span class="info-label">Your Message:</span>
|
|
||||||
<span class="info-value">{{ booking.additional_message }}</span>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="section">
|
|
||||||
<h2 class="section-title">What Happens Next?</h2>
|
|
||||||
<div class="steps">
|
|
||||||
<div class="step">
|
|
||||||
<strong>Review Process</strong><br>
|
|
||||||
Our clinical team will review your request to ensure we're the right fit for your needs.
|
|
||||||
</div>
|
|
||||||
<div class="step">
|
|
||||||
<strong>Confirmation</strong><br>
|
|
||||||
We'll contact you within 24 hours to confirm your appointment details.
|
|
||||||
</div>
|
|
||||||
<div class="step">
|
|
||||||
<strong>Session Preparation</strong><br>
|
|
||||||
You'll receive a confirmed date, time, and secure video meeting link.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="section">
|
|
||||||
<h2 class="section-title">Need Immediate Assistance?</h2>
|
|
||||||
<p>If you have any questions or need to modify your request, please don't hesitate to contact us:</p>
|
|
||||||
<div style="text-align: center; margin: 25px 0;">
|
|
||||||
<a href="tel:+19548073027" class="button">📞 Call Us: (954) 807-3027</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="section">
|
|
||||||
<p style="color: #6b7280; font-style: italic;">
|
|
||||||
"The journey of a thousand miles begins with a single step."<br>
|
|
||||||
- Lao Tzu
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="footer">
|
|
||||||
<p><strong>Attune Heart Therapy</strong></p>
|
|
||||||
<p>Healing Hearts, Transforming Lives</p>
|
|
||||||
<div class="contact-info">
|
|
||||||
📞 (954) 807-3027<br>
|
|
||||||
✉️ hello@attunehearttherapy.com<br>
|
|
||||||
🌐 attunehearttherapy.com
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
@ -1,112 +0,0 @@
|
|||||||
{% extends "emails/base.html" %}
|
|
||||||
|
|
||||||
{% block title %}Payment Confirmed - Attune Heart Therapy{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="email-header" style="background: linear-gradient(135deg, #10b981, #059669);">
|
|
||||||
<h1>💳 Payment Confirmed!</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="email-body">
|
|
||||||
<div class="greeting">
|
|
||||||
Dear <strong>{{ booking.full_name }}</strong>,
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="section">
|
|
||||||
<p>Thank you for your payment! Your <strong>{{ booking.get_appointment_type_display }}</strong> appointment is now fully confirmed and we're looking forward to our session.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="section">
|
|
||||||
<h2 class="section-title">Payment Details</h2>
|
|
||||||
<div class="info-card" style="background: linear-gradient(135deg, #f0fdf4, #dcfce7); border-left-color: #10b981;">
|
|
||||||
<div class="info-item">
|
|
||||||
<span class="info-label">Amount Paid:</span>
|
|
||||||
<span class="info-value" style="color: #059669; font-size: 18px; font-weight: 700;">${{ booking.amount }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="info-item">
|
|
||||||
<span class="info-label">Payment Date:</span>
|
|
||||||
<span class="info-value">{{ booking.paid_at|date:"F d, Y" }} at {{ booking.paid_at|time:"g:i A" }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="info-item">
|
|
||||||
<span class="info-label">Appointment:</span>
|
|
||||||
<span class="info-value">{{ booking.get_appointment_type_display }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="info-item">
|
|
||||||
<span class="info-label">Session Date:</span>
|
|
||||||
<span class="info-value">{{ booking.confirmed_datetime|date:"l, F d, Y" }} at {{ booking.confirmed_datetime|time:"g:i A" }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="section">
|
|
||||||
<h2 class="section-title">Your Session Details</h2>
|
|
||||||
<div class="info-card">
|
|
||||||
<div class="info-item">
|
|
||||||
<span class="info-label">Video Meeting:</span>
|
|
||||||
<span class="info-value">
|
|
||||||
<a href="{{ booking.jitsi_meet_url }}" style="color: #0ea5e9; text-decoration: none;">
|
|
||||||
{{ booking.jitsi_meet_url }}
|
|
||||||
</a>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="info-item">
|
|
||||||
<span class="info-label">Therapist:</span>
|
|
||||||
<span class="info-value">{{ booking.assigned_therapist.get_full_name }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="info-item">
|
|
||||||
<span class="info-label">Duration:</span>
|
|
||||||
<span class="info-value">
|
|
||||||
{% if booking.appointment_type == 'initial-consultation' %}90 minutes
|
|
||||||
{% elif booking.appointment_type == 'individual-therapy' %}60 minutes
|
|
||||||
{% elif booking.appointment_type == 'family-therapy' %}90 minutes
|
|
||||||
{% elif booking.appointment_type == 'couples-therapy' %}75 minutes
|
|
||||||
{% elif booking.appointment_type == 'group-therapy' %}90 minutes
|
|
||||||
{% elif booking.appointment_type == 'follow-up' %}45 minutes
|
|
||||||
{% else %}60 minutes{% endif %}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="text-align: center; margin: 30px 0;">
|
|
||||||
<a href="{{ booking.jitsi_meet_url }}" class="button" style="background: linear-gradient(135deg, #10b981, #059669);">
|
|
||||||
🎥 Join Video Session
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="section">
|
|
||||||
<div style="background: #f0f9ff; padding: 20px; border-radius: 8px; text-align: center;">
|
|
||||||
<h3 style="color: #0369a1; margin-bottom: 10px;">📋 Receipt</h3>
|
|
||||||
<p style="margin: 5px 0;">Payment ID: {{ booking.stripe_payment_intent_id|default:booking.id }}</p>
|
|
||||||
<p style="margin: 5px 0;">Date: {{ booking.paid_at|date:"Y-m-d" }}</p>
|
|
||||||
<p style="margin: 5px 0;">Amount: ${{ booking.amount }}</p>
|
|
||||||
<p style="margin: 5px 0; font-size: 12px; color: #64748b;">
|
|
||||||
This email serves as your receipt for tax purposes.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="section">
|
|
||||||
<p>If you have any questions about your payment or appointment, please don't hesitate to contact us.</p>
|
|
||||||
<div style="text-align: center; margin: 20px 0;">
|
|
||||||
<a href="tel:+19548073027" class="button" style="background: linear-gradient(135deg, #64748b, #475569);">
|
|
||||||
📞 Call (954) 807-3027
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="footer">
|
|
||||||
<p><strong>Attune Heart Therapy</strong></p>
|
|
||||||
<p>Thank you for trusting us with your care</p>
|
|
||||||
<div class="contact-info">
|
|
||||||
📞 (954) 807-3027<br>
|
|
||||||
✉️ hello@attunehearttherapy.com<br>
|
|
||||||
🌐 attunehearttherapy.com
|
|
||||||
</div>
|
|
||||||
<p style="font-size: 12px; color: #9ca3af; margin-top: 15px;">
|
|
||||||
Payment ID: {{ booking.stripe_payment_intent_id|default:booking.id }}<br>
|
|
||||||
Processed: {{ booking.paid_at|date:"Y-m-d H:i" }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
@ -1,4 +1,4 @@
|
|||||||
# Generated by Django 5.2.8 on 2025-11-22 02:11
|
# Generated by Django 5.2.8 on 2025-11-22 22:06
|
||||||
|
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|||||||
@ -3,7 +3,6 @@ from rest_framework_simplejwt.views import TokenRefreshView
|
|||||||
from . import views
|
from . import views
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('', views.api_root, name='api-root'),
|
|
||||||
|
|
||||||
path('register/', views.register_user, name='register'),
|
path('register/', views.register_user, name='register'),
|
||||||
path('login/', views.login_user, name='login'),
|
path('login/', views.login_user, name='login'),
|
||||||
|
|||||||
179
users/views.py
179
users/views.py
@ -12,185 +12,6 @@ from datetime import timedelta
|
|||||||
from rest_framework.reverse import reverse
|
from rest_framework.reverse import reverse
|
||||||
|
|
||||||
|
|
||||||
@api_view(['GET'])
|
|
||||||
@permission_classes([AllowAny])
|
|
||||||
def api_root(request, format=None):
|
|
||||||
"""
|
|
||||||
# Authentication API Documentation
|
|
||||||
|
|
||||||
Welcome to the Authentication API. This service provides complete user authentication functionality including registration, email verification, login, and password reset using OTP.
|
|
||||||
|
|
||||||
## Base URL
|
|
||||||
```
|
|
||||||
{{ request.build_absolute_uri }}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Quick Start
|
|
||||||
|
|
||||||
1. **Register** a new user account
|
|
||||||
2. **Verify** email with OTP sent to your email
|
|
||||||
3. **Login** with your credentials
|
|
||||||
4. Use the **access token** for authenticated requests
|
|
||||||
|
|
||||||
## API Endpoints
|
|
||||||
"""
|
|
||||||
|
|
||||||
endpoints = {
|
|
||||||
'documentation': {
|
|
||||||
'description': 'This API documentation',
|
|
||||||
'url': request.build_absolute_uri(),
|
|
||||||
'methods': ['GET']
|
|
||||||
},
|
|
||||||
'register': {
|
|
||||||
'description': 'Register a new user and send verification OTP',
|
|
||||||
'url': request.build_absolute_uri('register/'),
|
|
||||||
'methods': ['POST'],
|
|
||||||
'required_fields': ['email', 'first_name', 'last_name', 'password', 'password2'],
|
|
||||||
'example_request': {
|
|
||||||
'email': 'user@example.com',
|
|
||||||
'first_name': 'John',
|
|
||||||
'last_name': 'Doe',
|
|
||||||
'phone_number': '+1234567890',
|
|
||||||
'password': 'SecurePassword123',
|
|
||||||
'password2': 'SecurePassword123'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
'verify_otp': {
|
|
||||||
'description': 'Verify email address using OTP',
|
|
||||||
'url': request.build_absolute_uri('verify-otp/'),
|
|
||||||
'methods': ['POST'],
|
|
||||||
'required_fields': ['email', 'otp'],
|
|
||||||
'example_request': {
|
|
||||||
'email': 'user@example.com',
|
|
||||||
'otp': '123456'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
'login': {
|
|
||||||
'description': 'Authenticate user and return JWT tokens',
|
|
||||||
'url': request.build_absolute_uri('login/'),
|
|
||||||
'methods': ['POST'],
|
|
||||||
'required_fields': ['email', 'password'],
|
|
||||||
'example_request': {
|
|
||||||
'email': 'user@example.com',
|
|
||||||
'password': 'SecurePassword123'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
'resend_otp': {
|
|
||||||
'description': 'Resend OTP for email verification or password reset',
|
|
||||||
'url': request.build_absolute_uri('resend-otp/'),
|
|
||||||
'methods': ['POST'],
|
|
||||||
'required_fields': ['email'],
|
|
||||||
'optional_fields': ['context (registration/password_reset)'],
|
|
||||||
'example_request': {
|
|
||||||
'email': 'user@example.com',
|
|
||||||
'context': 'registration'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
'forgot_password': {
|
|
||||||
'description': 'Initiate password reset process',
|
|
||||||
'url': request.build_absolute_uri('forgot-password/'),
|
|
||||||
'methods': ['POST'],
|
|
||||||
'required_fields': ['email'],
|
|
||||||
'example_request': {
|
|
||||||
'email': 'user@example.com'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
'verify_password_reset_otp': {
|
|
||||||
'description': 'Verify OTP for password reset',
|
|
||||||
'url': request.build_absolute_uri('verify-password-reset-otp/'),
|
|
||||||
'methods': ['POST'],
|
|
||||||
'required_fields': ['email', 'otp'],
|
|
||||||
'example_request': {
|
|
||||||
'email': 'user@example.com',
|
|
||||||
'otp': '123456'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
'reset_password': {
|
|
||||||
'description': 'Reset password after OTP verification',
|
|
||||||
'url': request.build_absolute_uri('reset-password/'),
|
|
||||||
'methods': ['POST'],
|
|
||||||
'required_fields': ['email', 'otp', 'new_password', 'confirm_password'],
|
|
||||||
'example_request': {
|
|
||||||
'email': 'user@example.com',
|
|
||||||
'otp': '123456',
|
|
||||||
'new_password': 'NewSecurePassword123',
|
|
||||||
'confirm_password': 'NewSecurePassword123'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
'token_refresh': {
|
|
||||||
'description': 'Refresh access token using refresh token',
|
|
||||||
'url': request.build_absolute_uri('token/refresh/'),
|
|
||||||
'methods': ['POST'],
|
|
||||||
'required_fields': ['refresh'],
|
|
||||||
'example_request': {
|
|
||||||
'refresh': 'your_refresh_token_here'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Response({
|
|
||||||
'message': 'Authentication API',
|
|
||||||
'version': '1.0.0',
|
|
||||||
'endpoints': endpoints,
|
|
||||||
'authentication_flows': {
|
|
||||||
'registration_flow': [
|
|
||||||
'1. POST /register/ - Register user and send OTP',
|
|
||||||
'2. POST /verify-otp/ - Verify email with OTP',
|
|
||||||
'3. POST /login/ - Login with credentials'
|
|
||||||
],
|
|
||||||
'password_reset_flow': [
|
|
||||||
'1. POST /forgot-password/ - Request password reset OTP',
|
|
||||||
'2. POST /verify-password-reset-otp/ - Verify OTP',
|
|
||||||
'3. POST /reset-password/ - Set new password'
|
|
||||||
],
|
|
||||||
'login_flow_unverified': [
|
|
||||||
'1. POST /login/ - Returns email not verified error',
|
|
||||||
'2. POST /resend-otp/ - Resend verification OTP',
|
|
||||||
'3. POST /verify-otp/ - Verify email',
|
|
||||||
'4. POST /login/ - Successful login'
|
|
||||||
]
|
|
||||||
},
|
|
||||||
'specifications': {
|
|
||||||
'otp': {
|
|
||||||
'length': 6,
|
|
||||||
'expiry_minutes': 10,
|
|
||||||
'delivery_method': 'email'
|
|
||||||
},
|
|
||||||
'tokens': {
|
|
||||||
'access_token_lifetime': '5 minutes',
|
|
||||||
'refresh_token_lifetime': '24 hours'
|
|
||||||
},
|
|
||||||
'password_requirements': [
|
|
||||||
'Minimum 8 characters',
|
|
||||||
'Cannot be entirely numeric',
|
|
||||||
'Cannot be too common',
|
|
||||||
'Should include uppercase, lowercase, and numbers'
|
|
||||||
]
|
|
||||||
},
|
|
||||||
'error_handling': {
|
|
||||||
'common_status_codes': {
|
|
||||||
'200': 'Success',
|
|
||||||
'201': 'Created',
|
|
||||||
'400': 'Bad Request (validation errors)',
|
|
||||||
'401': 'Unauthorized (invalid credentials)',
|
|
||||||
'403': 'Forbidden (unverified email, inactive account)',
|
|
||||||
'404': 'Not Found',
|
|
||||||
'500': 'Internal Server Error'
|
|
||||||
},
|
|
||||||
'error_response_format': {
|
|
||||||
'error': 'Error description',
|
|
||||||
'message': 'User-friendly message'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
'security_notes': [
|
|
||||||
'Always use HTTPS in production',
|
|
||||||
'Store tokens securely (httpOnly cookies recommended)',
|
|
||||||
'Implement token refresh logic',
|
|
||||||
'Validate all inputs on frontend and backend',
|
|
||||||
'Handle token expiration gracefully'
|
|
||||||
]
|
|
||||||
})
|
|
||||||
|
|
||||||
@api_view(['POST'])
|
@api_view(['POST'])
|
||||||
@permission_classes([AllowAny])
|
@permission_classes([AllowAny])
|
||||||
def register_user(request):
|
def register_user(request):
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user