diff --git a/.gitignore b/.gitignore index 74ebefb..bf4ca0c 100644 --- a/.gitignore +++ b/.gitignore @@ -127,8 +127,6 @@ __pypackages__/ celerybeat-schedule celerybeat.pid -meetings - # SageMath parsed files *.sage.py diff --git a/booking_system/settings.py b/booking_system/settings.py index 1e4dd0e..bfd7ca3 100644 --- a/booking_system/settings.py +++ b/booking_system/settings.py @@ -34,7 +34,7 @@ INSTALLED_APPS = [ 'corsheaders', 'users', - # 'meetings', + 'meetings', ] MIDDLEWARE = [ @@ -48,6 +48,8 @@ MIDDLEWARE = [ 'django.middleware.clickjacking.XFrameOptionsMiddleware', ] +SITE_NAME = os.getenv('SITE_NAME', 'Attune Heart Therapy') + ROOT_URLCONF = 'booking_system.urls' TEMPLATES = [ @@ -70,23 +72,25 @@ TEMPLATES = [ WSGI_APPLICATION = 'booking_system.wsgi.application' +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': BASE_DIR / 'db.sqlite3', + } +} + # DATABASES = { # 'default': { -# 'ENGINE': 'django.db.backends.sqlite3', -# 'NAME': BASE_DIR / 'db.sqlite3', +# '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), # } # } -DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.postgresql', - 'NAME': os.getenv('POSTGRES_DB'), - 'USER': os.getenv('POSTGRES_USER'), - 'PASSWORD': os.getenv('POSTGRES_PASSWORD'), - 'HOST': os.getenv('POSTGRES_HOST', 'postgres'), - 'PORT': os.getenv('POSTGRES_PORT', 5432), - } -} +ENCRYPTION_KEY = os.getenv('ENCRYPTION_KEY') @@ -113,11 +117,8 @@ SIMPLE_JWT = { 'BLACKLIST_AFTER_ROTATION': True, 'SIGNING_KEY': os.getenv('JWT_SECRET', SECRET_KEY), 'AUTH_HEADER_TYPES': ('Bearer',), - } -# HIPAA Email Configuration -EMAIL_ENCRYPTION_KEY = os.getenv('EMAIL_ENCRYPTION_KEY') # Stripe Configuration 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') -# 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_BASE_URL = os.getenv('JITSI_BASE_URL', 'https://meet.jit.si') diff --git a/booking_system/urls.py b/booking_system/urls.py index a452274..d8df884 100644 --- a/booking_system/urls.py +++ b/booking_system/urls.py @@ -1,8 +1,10 @@ -from django.contrib import admin from django.urls import path, include +from django.contrib import admin +from .views import api_root urlpatterns = [ path('admin/', admin.site.urls), path('api/auth/', include('users.urls')), - # path('api/', include('meetings.urls')), + path('api/meetings/', include('meetings.urls')), + path('', api_root, name='api-root'), ] \ No newline at end of file diff --git a/booking_system/views.py b/booking_system/views.py new file mode 100644 index 0000000..4183912 --- /dev/null +++ b/booking_system/views.py @@ -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//", + "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//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//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//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_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' + } + } + }) \ No newline at end of file diff --git a/meetings/__init__.py b/meetings/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/meetings/admin.py b/meetings/admin.py new file mode 100644 index 0000000..b9a029f --- /dev/null +++ b/meetings/admin.py @@ -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" \ No newline at end of file diff --git a/meetings/apps.py b/meetings/apps.py new file mode 100644 index 0000000..cdf31df --- /dev/null +++ b/meetings/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class MeetingsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'meetings' diff --git a/meetings/email_service.py b/meetings/email_service.py new file mode 100644 index 0000000..c1067fa --- /dev/null +++ b/meetings/email_service.py @@ -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 \ No newline at end of file diff --git a/meetings/migrations/0001_initial.py b/meetings/migrations/0001_initial.py new file mode 100644 index 0000000..72a8f26 --- /dev/null +++ b/meetings/migrations/0001_initial.py @@ -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'], + }, + ), + ] diff --git a/meetings/migrations/0002_appointmentrequest_jitsi_meet_url_and_more.py b/meetings/migrations/0002_appointmentrequest_jitsi_meet_url_and_more.py new file mode 100644 index 0000000..2bac344 --- /dev/null +++ b/meetings/migrations/0002_appointmentrequest_jitsi_meet_url_and_more.py @@ -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'), + ), + ] diff --git a/meetings/migrations/__init__.py b/meetings/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/meetings/models.py b/meetings/models.py new file mode 100644 index 0000000..31a5391 --- /dev/null +++ b/meetings/models.py @@ -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')}" \ No newline at end of file diff --git a/meetings/serializers.py b/meetings/serializers.py new file mode 100644 index 0000000..4e6bc22 --- /dev/null +++ b/meetings/serializers.py @@ -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) \ No newline at end of file diff --git a/meetings/tasks.py b/meetings/tasks.py new file mode 100644 index 0000000..01601de --- /dev/null +++ b/meetings/tasks.py @@ -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)}") \ No newline at end of file diff --git a/meetings/tests.py b/meetings/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/meetings/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/meetings/urls.py b/meetings/urls.py new file mode 100644 index 0000000..4db38be --- /dev/null +++ b/meetings/urls.py @@ -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//', AppointmentRequestDetailView.as_view(), name='appointment-detail'), + + path('appointments//schedule/', schedule_appointment, name='appointment-schedule'), + path('appointments//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'), +] \ No newline at end of file diff --git a/meetings/views.py b/meetings/views.py new file mode 100644 index 0000000..c001810 --- /dev/null +++ b/meetings/views.py @@ -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 + }) \ No newline at end of file diff --git a/templates/emails/admin_booking_notification.html b/templates/emails/admin_booking_notification.html deleted file mode 100644 index b5067bc..0000000 --- a/templates/emails/admin_booking_notification.html +++ /dev/null @@ -1,100 +0,0 @@ -{% extends "emails/base.html" %} - -{% block title %}New Therapy Booking Request - Action Required{% endblock %} - -{% block content %} - - - - - -{% endblock %} \ No newline at end of file diff --git a/templates/emails/admin_notification.html b/templates/emails/admin_notification.html new file mode 100644 index 0000000..63496fc --- /dev/null +++ b/templates/emails/admin_notification.html @@ -0,0 +1,304 @@ + + + + + + New Appointment Request - Action Required + + + + + + diff --git a/templates/emails/appointment_rejected.html b/templates/emails/appointment_rejected.html new file mode 100644 index 0000000..87b43d9 --- /dev/null +++ b/templates/emails/appointment_rejected.html @@ -0,0 +1,316 @@ + + + + + + Appointment Request Update + + + + + + diff --git a/templates/emails/appointment_scheduled.html b/templates/emails/appointment_scheduled.html new file mode 100644 index 0000000..4a06dee --- /dev/null +++ b/templates/emails/appointment_scheduled.html @@ -0,0 +1,320 @@ + + + + + + Appointment Confirmed + + + + + + diff --git a/templates/emails/booking_confirmed.html b/templates/emails/booking_confirmed.html deleted file mode 100644 index b69ffd5..0000000 --- a/templates/emails/booking_confirmed.html +++ /dev/null @@ -1,145 +0,0 @@ -{% extends "emails/base.html" %} - -{% block title %}Appointment Confirmed - Attune Heart Therapy{% endblock %} - -{% block content %} - - - - - -{% endblock %} \ No newline at end of file diff --git a/templates/emails/patient_booking_confirmation.html b/templates/emails/patient_booking_confirmation.html deleted file mode 100644 index 9e7ac5e..0000000 --- a/templates/emails/patient_booking_confirmation.html +++ /dev/null @@ -1,86 +0,0 @@ -{% extends "emails/base.html" %} - -{% block title %}Booking Request Received - Attune Heart Therapy{% endblock %} - -{% block content %} - - - - - -{% endblock %} \ No newline at end of file diff --git a/templates/emails/payment_confirmed.html b/templates/emails/payment_confirmed.html deleted file mode 100644 index 19e4c7a..0000000 --- a/templates/emails/payment_confirmed.html +++ /dev/null @@ -1,112 +0,0 @@ -{% extends "emails/base.html" %} - -{% block title %}Payment Confirmed - Attune Heart Therapy{% endblock %} - -{% block content %} - - - - - -{% endblock %} \ No newline at end of file diff --git a/users/migrations/0001_initial.py b/users/migrations/0001_initial.py index 03b57ac..d03ee6e 100644 --- a/users/migrations/0001_initial.py +++ b/users/migrations/0001_initial.py @@ -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 from django.conf import settings diff --git a/users/urls.py b/users/urls.py index 0ce4c69..f077739 100644 --- a/users/urls.py +++ b/users/urls.py @@ -3,7 +3,6 @@ from rest_framework_simplejwt.views import TokenRefreshView from . import views urlpatterns = [ - path('', views.api_root, name='api-root'), path('register/', views.register_user, name='register'), path('login/', views.login_user, name='login'), diff --git a/users/views.py b/users/views.py index 5c311a7..e0b58fe 100644 --- a/users/views.py +++ b/users/views.py @@ -12,185 +12,6 @@ from datetime import timedelta 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']) @permission_classes([AllowAny]) def register_user(request):