feat: enable meetings app and simplify development configuration
- Enable meetings app in INSTALLED_APPS and add URL routing - Switch from PostgreSQL to SQLite for default database configuration - Remove meetings directory from .gitignore - Move API root endpoint from users app to main URL configuration - Remove HIPAA-specific email and compliance settings (EMAIL_ENCRYPTION_KEY, HIPAA_EMAIL_CONFIG, BAA_VERIFICATION) - Add SITE_NAME and ENCRYPTION_KEY environment variables - Regenerate initial user migrations These changes simplify the development setup by using SQLite as the default database and removing complex compliance configurations while enabling the core meetings functionality.
This commit is contained in:
parent
8ddd6fe77f
commit
1fc91d5949
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