diff --git a/.gitignore b/.gitignore index dac1739..64c193b 100644 --- a/.gitignore +++ b/.gitignore @@ -18,8 +18,6 @@ media *.py[cod] *$py.class -meetings - # C extensions *.so @@ -121,6 +119,8 @@ ipython_config.py # https://pdm.fming.dev/#use-with-ide .pdm.toml +meetings + # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm __pypackages__/ diff --git a/booking_system/settings.py b/booking_system/settings.py index e4b877e..a7cb8cd 100644 --- a/booking_system/settings.py +++ b/booking_system/settings.py @@ -46,7 +46,9 @@ ROOT_URLCONF = 'booking_system.urls' TEMPLATES = [ { 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [], + 'DIRS': [ + os.path.join(BASE_DIR, 'templates'), + ], 'APP_DIRS': True, 'OPTIONS': { 'context_processors': [ @@ -95,12 +97,35 @@ SIMPLE_JWT = { } +# HIPAA Email Configuration +EMAIL_ENCRYPTION_KEY = os.getenv('EMAIL_ENCRYPTION_KEY') + # Stripe Configuration STRIPE_PUBLISHABLE_KEY = os.getenv('STRIPE_PUBLISHABLE_KEY') 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/requirements.txt b/requirements.txt index f6bbd53..5dcc9f6 100644 Binary files a/requirements.txt and b/requirements.txt differ diff --git a/templates/emails/admin_booking_notification.html b/templates/emails/admin_booking_notification.html new file mode 100644 index 0000000..b5067bc --- /dev/null +++ b/templates/emails/admin_booking_notification.html @@ -0,0 +1,100 @@ +{% extends "emails/base.html" %} + +{% block title %}New Therapy Booking Request - Action Required{% endblock %} + +{% block content %} +
+

📋 NEW THERAPY BOOKING REQUEST

+
+ +
+
⏰ ACTION REQUIRED - Please respond within 24 hours
+ +
+

Patient Information

+
+
+ Full Name: + {{ booking.full_name }} +
+
+ Email: + {{ booking.email }} +
+
+ Phone: + {{ booking.phone }} +
+
+ Submitted: + {{ booking.created_at|date:"F d, Y" }} at {{ booking.created_at|time:"g:i A" }} +
+
+
+ +
+

Appointment Details

+
+
+ Appointment Type: + {{ booking.get_appointment_type_display }} +
+
+ Preferred Date: + {{ booking.preferred_date|date:"l, F d, Y" }} +
+
+ Preferred Time: + {{ booking.preferred_time }} +
+
+ Session Fee: + ${{ booking.amount }} +
+
+
+ + {% if booking.additional_message %} +
+

Patient's Message

+
+
+ {{ booking.additional_message }} +
+
+
+ {% endif %} + +
+

Required Actions

+
+
+ Review Patient Information
+ Assess clinical appropriateness and availability. +
+
+ Contact Patient
+ Reach out within 24 hours to confirm appointment details. +
+
+ Confirm Booking
+ Update booking status and send confirmation email. +
+
+
+ +
+ + 📊 Manage This Booking + +
+
+ + +{% endblock %} \ No newline at end of file diff --git a/templates/emails/base.html b/templates/emails/base.html new file mode 100644 index 0000000..cf97ab0 --- /dev/null +++ b/templates/emails/base.html @@ -0,0 +1,196 @@ + + + + + + {% block title %}Attune Heart Therapy{% endblock %} + + + +
+ {% block content %}{% endblock %} +
+ + \ No newline at end of file diff --git a/templates/emails/booking_confirmed.html b/templates/emails/booking_confirmed.html new file mode 100644 index 0000000..b69ffd5 --- /dev/null +++ b/templates/emails/booking_confirmed.html @@ -0,0 +1,145 @@ +{% extends "emails/base.html" %} + +{% block title %}Appointment Confirmed - Attune Heart Therapy{% endblock %} + +{% block content %} +
+

✅ Your Appointment is Confirmed!

+
+ +
+
+ Dear {{ booking.full_name }}, +
+ +
+

We're delighted to confirm your {{ booking.get_appointment_type_display }} appointment. Your healing journey begins now, and we're honored to walk this path with you.

+
+ +
+

Appointment Details

+
+
+ Appointment Type: + {{ booking.get_appointment_type_display }} +
+
+ Date & Time: + {{ booking.confirmed_datetime|date:"l, F d, Y" }} at {{ booking.confirmed_datetime|time:"g:i A" }} +
+
+ Duration: + + {% 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 %} + +
+
+ Therapist: + {{ booking.assigned_therapist.get_full_name }} +
+ {% if booking.payment_status == 'paid' %} +
+ Payment Status: + ✅ Paid +
+ {% endif %} +
+
+ +
+

Join Your Session

+
+
+
+ 📅 Secure Video Session +
+ + 🎥 Join Video Session + +
+ Or copy this link:
+ {{ booking.jitsi_meet_url }} +
+
+
+
+ +
+

Preparing for Your Session

+
+
+ Test Your Technology
+ Please test your camera, microphone, and internet connection before the session. +
+
+ Find a Quiet Space
+ Choose a private, comfortable location where you won't be interrupted. +
+
+ Join Early
+ Please join 5-10 minutes before your scheduled time to ensure everything is working. +
+
+ Browser Recommendation
+ Use Chrome, Firefox, or Safari for the best video experience. +
+
+
+ + {% if booking.payment_status != 'paid' %} +
+

Payment Information

+
+
+
+ 💳 Payment Required +
+

Your session fee of ${{ booking.amount }} is pending. Please complete your payment before the session.

+ + Complete Payment + +
+
+
+ {% endif %} + +
+

Need to Reschedule?

+

If you need to reschedule or cancel your appointment, please contact us at least 24 hours in advance:

+
+ + 📞 Call (954) 807-3027 + +
+
+ +
+
+

+ "The privilege of a lifetime is to become who you truly are."
+ - Carl Jung +

+
+
+
+ + +{% endblock %} \ No newline at end of file diff --git a/templates/emails/otp_verification.html b/templates/emails/otp_verification.html new file mode 100644 index 0000000..c2f205b --- /dev/null +++ b/templates/emails/otp_verification.html @@ -0,0 +1,179 @@ + + + + + + Email Verification + + + +
+ +
+

Verify Your Email Address

+

Secure your account with one-time password

+
+ + +
+

Hello {{ user_name }},

+

+ Thank you for registering with us! To complete your registration and + secure your account, please use the following verification code: +

+ + +
+
{{ otp }}
+
+ +
+ Important: This code will expire in + {{ expiry_minutes }} minutes for security reasons. +
+ +

+ If you didn't request this code, please ignore this email or contact + our support team immediately. +

+
+ + + +
+ + diff --git a/templates/emails/password_reset_otp.html b/templates/emails/password_reset_otp.html new file mode 100644 index 0000000..57f4c11 --- /dev/null +++ b/templates/emails/password_reset_otp.html @@ -0,0 +1,170 @@ + + + + + + Password Reset + + + +
+
+

Password Reset Request

+

Secure your account with verification code

+
+
+

Hello {{ user_name }},

+

+ We received a request to reset your password for your account. Use the + verification code below to proceed with resetting your password: +

+ +
+
{{ otp }}
+
+ +
+ Important: This code will expire in + {{ expiry_minutes }} minutes for security reasons. +
+ +
+ Note: This password reset request was initiated from + our system. If this wasn't you, your account might be at risk. +
+
+ + +
+ + diff --git a/templates/emails/patient_booking_confirmation.html b/templates/emails/patient_booking_confirmation.html new file mode 100644 index 0000000..9e7ac5e --- /dev/null +++ b/templates/emails/patient_booking_confirmation.html @@ -0,0 +1,86 @@ +{% extends "emails/base.html" %} + +{% block title %}Booking Request Received - Attune Heart Therapy{% endblock %} + +{% block content %} +
+

🎉 Thank You for Your Booking Request!

+
+ +
+
+ Dear {{ booking.full_name }}, +
+ +
+

We have received your request for a {{ booking.get_appointment_type_display }} appointment and we're excited to support you on your healing journey.

+
+ +
+

Your Request Details

+
+
+ Appointment Type: + {{ booking.get_appointment_type_display }} +
+
+ Preferred Date: + {{ booking.preferred_date|date:"l, F d, Y" }} +
+
+ Preferred Time: + {{ booking.preferred_time }} +
+ {% if booking.additional_message %} +
+ Your Message: + {{ booking.additional_message }} +
+ {% endif %} +
+
+ +
+

What Happens Next?

+
+
+ Review Process
+ Our clinical team will review your request to ensure we're the right fit for your needs. +
+
+ Confirmation
+ We'll contact you within 24 hours to confirm your appointment details. +
+
+ Session Preparation
+ You'll receive a confirmed date, time, and secure video meeting link. +
+
+
+ +
+

Need Immediate Assistance?

+

If you have any questions or need to modify your request, please don't hesitate to contact us:

+
+ 📞 Call Us: (954) 807-3027 +
+
+ +
+

+ "The journey of a thousand miles begins with a single step."
+ - Lao Tzu +

+
+
+ + +{% endblock %} \ No newline at end of file diff --git a/templates/emails/payment_confirmed.html b/templates/emails/payment_confirmed.html new file mode 100644 index 0000000..19e4c7a --- /dev/null +++ b/templates/emails/payment_confirmed.html @@ -0,0 +1,112 @@ +{% extends "emails/base.html" %} + +{% block title %}Payment Confirmed - Attune Heart Therapy{% endblock %} + +{% block content %} +
+

💳 Payment Confirmed!

+
+ +
+
+ Dear {{ booking.full_name }}, +
+ +
+

Thank you for your payment! Your {{ booking.get_appointment_type_display }} appointment is now fully confirmed and we're looking forward to our session.

+
+ +
+

Payment Details

+
+
+ Amount Paid: + ${{ booking.amount }} +
+
+ Payment Date: + {{ booking.paid_at|date:"F d, Y" }} at {{ booking.paid_at|time:"g:i A" }} +
+
+ Appointment: + {{ booking.get_appointment_type_display }} +
+
+ Session Date: + {{ booking.confirmed_datetime|date:"l, F d, Y" }} at {{ booking.confirmed_datetime|time:"g:i A" }} +
+
+
+ +
+

Your Session Details

+
+
+ Video Meeting: + + + {{ booking.jitsi_meet_url }} + + +
+
+ Therapist: + {{ booking.assigned_therapist.get_full_name }} +
+
+ Duration: + + {% 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 %} + +
+
+
+ +
+ + 🎥 Join Video Session + +
+ +
+
+

📋 Receipt

+

Payment ID: {{ booking.stripe_payment_intent_id|default:booking.id }}

+

Date: {{ booking.paid_at|date:"Y-m-d" }}

+

Amount: ${{ booking.amount }}

+

+ This email serves as your receipt for tax purposes. +

+
+
+ +
+

If you have any questions about your payment or appointment, please don't hesitate to contact us.

+
+ + 📞 Call (954) 807-3027 + +
+
+
+ + +{% endblock %} \ No newline at end of file diff --git a/templates/emails/booking_confirmation.html b/templates/secure/email_portal.html similarity index 100% rename from templates/emails/booking_confirmation.html rename to templates/secure/email_portal.html diff --git a/users/migrations/0001_initial.py b/users/migrations/0001_initial.py index 98d3e3f..03b57ac 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-13 00:35 +# Generated by Django 5.2.8 on 2025-11-22 02:11 import django.db.models.deletion from django.conf import settings @@ -25,6 +25,11 @@ class Migration(migrations.Migration): ('is_staff', models.BooleanField(default=False)), ('is_superuser', models.BooleanField(default=False)), ('is_active', models.BooleanField(default=True)), + ('isVerified', models.BooleanField(default=False)), + ('verify_otp', models.CharField(blank=True, max_length=6, null=True)), + ('verify_otp_expiry', models.DateTimeField(blank=True, null=True)), + ('forgot_password_otp', models.CharField(blank=True, max_length=6, null=True)), + ('forgot_password_otp_expiry', models.DateTimeField(blank=True, null=True)), ('phone_number', models.CharField(blank=True, max_length=20)), ('last_login', models.DateTimeField(auto_now=True)), ('date_joined', models.DateTimeField(auto_now_add=True)), diff --git a/users/models.py b/users/models.py index 33c68e0..ac1f77f 100644 --- a/users/models.py +++ b/users/models.py @@ -9,6 +9,11 @@ class CustomUser(AbstractBaseUser, PermissionsMixin): is_staff = models.BooleanField(default=False) is_superuser = models.BooleanField(default=False) is_active = models.BooleanField(default=True) + isVerified = models.BooleanField(default=False) + verify_otp = models.CharField(max_length=6, blank=True, null=True) + verify_otp_expiry = models.DateTimeField(null=True, blank=True) + forgot_password_otp = models.CharField(max_length=6, blank=True, null=True) + forgot_password_otp_expiry = models.DateTimeField(null=True, blank=True) phone_number = models.CharField(max_length=20, blank=True) last_login = models.DateTimeField(auto_now=True) date_joined = models.DateTimeField(auto_now_add=True) @@ -20,6 +25,9 @@ class CustomUser(AbstractBaseUser, PermissionsMixin): def __str__(self): return self.email + + def get_full_name(self): + return f"{self.first_name} {self.last_name}" class UserProfile(models.Model): user = models.OneToOneField(CustomUser, on_delete=models.CASCADE, related_name='profile') diff --git a/users/serializers.py b/users/serializers.py index 47f1901..7bd00d1 100644 --- a/users/serializers.py +++ b/users/serializers.py @@ -10,34 +10,47 @@ class UserProfileSerializer(serializers.ModelSerializer): class UserRegistrationSerializer(serializers.ModelSerializer): password = serializers.CharField(write_only=True, required=True, validators=[validate_password]) password2 = serializers.CharField(write_only=True, required=True) - profile = UserProfileSerializer(read_only=True) - + class Meta: model = CustomUser - fields = ['email', 'password', 'password2', 'first_name', 'last_name', 'profile'] - extra_kwargs = { - 'first_name': {'required': True}, - 'last_name': {'required': True} - } - + fields = ('email', 'first_name', 'last_name', 'phone_number', 'password', 'password2') + def validate(self, attrs): if attrs['password'] != attrs['password2']: raise serializers.ValidationError({"password": "Password fields didn't match."}) return attrs - + def create(self, validated_data): validated_data.pop('password2') - user = CustomUser.objects.create_user( - email=validated_data['email'], - password=validated_data['password'], - first_name=validated_data['first_name'], - last_name=validated_data['last_name'], - ) + password = validated_data.pop('password') + + user = CustomUser.objects.create_user(**validated_data) + user.set_password(password) + user.is_active = True + user.isVerified = False + user.save() + return user + +class ForgotPasswordSerializer(serializers.Serializer): + email = serializers.EmailField(required=True) + +class VerifyPasswordResetOTPSerializer(serializers.Serializer): + email = serializers.EmailField(required=True) + otp = serializers.CharField(required=True, max_length=6) + +class ResetPasswordSerializer(serializers.Serializer): + email = serializers.EmailField(required=True) + otp = serializers.CharField(required=True, max_length=6) + new_password = serializers.CharField(required=True, write_only=True, validators=[validate_password]) + confirm_password = serializers.CharField(required=True, write_only=True) + + def validate(self, attrs): + if attrs['new_password'] != attrs['confirm_password']: + raise serializers.ValidationError({"password": "Password fields didn't match."}) + return attrs class UserSerializer(serializers.ModelSerializer): - profile = UserProfileSerializer(read_only=True) - class Meta: model = CustomUser - fields = ['id', 'email', 'first_name', 'last_name', 'phone_number', 'profile'] \ No newline at end of file + fields = ('id', 'email', 'first_name', 'last_name', 'phone_number', 'isVerified', 'date_joined') \ No newline at end of file diff --git a/users/urls.py b/users/urls.py index 5efd45c..0ce4c69 100644 --- a/users/urls.py +++ b/users/urls.py @@ -3,8 +3,20 @@ 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'), + path('verify-otp/', views.verify_otp, name='verify-otp'), + path('resend-otp/', views.resend_otp, name='resend-otp'), + + + path('forgot-password/', views.forgot_password, name='forgot-password'), + path('verify-password-reset-otp/', views.verify_password_reset_otp, name='verify-password-reset-otp'), + path('reset-password/', views.reset_password, name='reset-password'), + path('resend-password-reset-otp/', views.resend_password_reset_otp, name='resend-password-reset-otp'), + + path('token/refresh/', TokenRefreshView.as_view(), name='token_refresh'), path('profile/', views.get_user_profile, name='profile'), path('profile/update/', views.update_user_profile, name='update_profile'), diff --git a/users/utils.py b/users/utils.py new file mode 100644 index 0000000..2a46931 --- /dev/null +++ b/users/utils.py @@ -0,0 +1,57 @@ +# utils/otp_utils.py +import random +from django.utils import timezone +from datetime import timedelta +from django.conf import settings +from django.core.mail import EmailMultiAlternatives +from django.template.loader import render_to_string +from django.utils.html import strip_tags + +def generate_otp(): + return str(random.randint(100000, 999999)) + +def send_otp_via_email(email, otp, user_name=None, context='registration'): + try: + context_data = { + 'user_name': user_name or 'User', + 'otp': otp, + 'expiry_minutes': 10, + 'company_name': 'Attune Heart Therapy', + 'support_email': 'hello@attunehearttherapy.com', + 'current_year': timezone.now().year, + 'email_context': context + } + + if context == 'password_reset': + template_name = 'emails/password_reset_otp.html' + subject = 'Password Reset Request - Verification Code' + else: + template_name = 'emails/otp_verification.html' + subject = 'Your Verification Code - Secure Your Account' + + html_content = render_to_string(template_name, context_data) + + text_content = strip_tags(html_content) + + from_email = settings.DEFAULT_FROM_EMAIL + to_email = [email] + + email_msg = EmailMultiAlternatives( + subject, + text_content, + from_email, + to_email + ) + email_msg.attach_alternative(html_content, "text/html") + email_msg.send() + + return True + + except Exception as e: + print(f"Error sending email OTP: {e}") + return False + +def is_otp_expired(otp_expiry): + if otp_expiry and timezone.now() < otp_expiry: + return False + return True \ No newline at end of file diff --git a/users/views.py b/users/views.py index 9abedab..5c311a7 100644 --- a/users/views.py +++ b/users/views.py @@ -5,7 +5,191 @@ from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework_simplejwt.tokens import RefreshToken from django.contrib.auth import authenticate from .models import CustomUser, UserProfile -from .serializers import UserRegistrationSerializer, UserSerializer +from .serializers import UserRegistrationSerializer, UserSerializer, ResetPasswordSerializer, ForgotPasswordSerializer, VerifyPasswordResetOTPSerializer +from .utils import send_otp_via_email, is_otp_expired, generate_otp +from django.utils import timezone +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]) @@ -14,20 +198,126 @@ def register_user(request): if serializer.is_valid(): user = serializer.save() - # Create user profile UserProfile.objects.create(user=user) - # Generate tokens - refresh = RefreshToken.for_user(user) + otp = generate_otp() + user.verify_otp = otp + user.verify_otp_expiry = timezone.now() + timedelta(minutes=10) + user.save() + + user_name = f"{user.first_name} {user.last_name}".strip() or user.email + email_sent = send_otp_via_email(user.email, otp, user_name, 'registration') + + if not email_sent: + return Response({ + 'error': 'Failed to send OTP. Please try again later.' + }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) return Response({ 'user': UserSerializer(user).data, - 'refresh': str(refresh), - 'access': str(refresh.access_token), + 'otp_sent': email_sent, + 'otp_expires_in': 10 }, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) +@api_view(['POST']) +@permission_classes([AllowAny]) +def verify_otp(request): + email = request.data.get('email') + otp = request.data.get('otp') + + if not email or not otp: + return Response({ + 'error': 'Email and OTP are required' + }, status=status.HTTP_400_BAD_REQUEST) + + try: + user = CustomUser.objects.get(email=email) + + if user.isVerified: + return Response({ + 'error': 'User is already verified' + }, status=status.HTTP_400_BAD_REQUEST) + + if (user.verify_otp == otp and + not is_otp_expired(user.verify_otp_expiry)): + + user.isVerified = True + user.verify_otp = None + user.verify_otp_expiry = None + user.save() + + refresh = RefreshToken.for_user(user) + + return Response({ + 'message': 'Email verified successfully', + 'verified': True, + }, status=status.HTTP_200_OK) + else: + return Response({ + 'error': 'Invalid or expired OTP' + }, status=status.HTTP_400_BAD_REQUEST) + + except CustomUser.DoesNotExist: + return Response({ + 'error': 'User not found' + }, status=status.HTTP_404_NOT_FOUND) + + +@api_view(['POST']) +@permission_classes([AllowAny]) +def resend_otp(request): + email = request.data.get('email') + context = request.data.get('context', 'registration') + + if not email: + return Response({ + 'error': 'Email is required' + }, status=status.HTTP_400_BAD_REQUEST) + + try: + user = CustomUser.objects.get(email=email) + + if user.isVerified and context == 'registration': + return Response({ + 'error': 'Already verified', + 'message': 'Your email is already verified. You can login now.' + }, status=status.HTTP_400_BAD_REQUEST) + + otp = generate_otp() + + if context == 'password_reset': + user.forgot_password_otp = otp + user.forgot_password_otp_expiry = timezone.now() + timedelta(minutes=10) + else: + user.verify_otp = otp + user.verify_otp_expiry = timezone.now() + timedelta(minutes=10) + + user.save() + + user_name = f"{user.first_name} {user.last_name}".strip() or user.email + email_sent = send_otp_via_email(user.email, otp, user_name, context) + + if not email_sent: + return Response({ + 'error': 'Failed to send OTP', + 'message': 'Please try again later.' + }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + return Response({ + 'message': f'OTP resent to your email successfully', + 'otp_sent': email_sent, + 'otp_expires_in': 10, + 'context': context + }, status=status.HTTP_200_OK) + + except CustomUser.DoesNotExist: + return Response({ + 'error': 'User not found', + 'message': 'No account found with this email address.' + }, status=status.HTTP_404_NOT_FOUND) + @api_view(['POST']) @permission_classes([AllowAny]) def login_user(request): @@ -37,11 +327,27 @@ def login_user(request): user = authenticate(request, email=email, password=password) if user is not None: + if not user.isVerified: + return Response({ + 'error': 'Email not verified', + 'message': 'Please verify your email address before logging in.', + 'email': user.email, + 'can_resend_otp': True + }, status=status.HTTP_403_FORBIDDEN) + + if not user.is_active: + return Response({ + 'error': 'Account deactivated', + 'message': 'Your account has been deactivated. Please contact support.' + }, status=status.HTTP_403_FORBIDDEN) + refresh = RefreshToken.for_user(user) + return Response({ 'user': UserSerializer(user).data, 'refresh': str(refresh), 'access': str(refresh.access_token), + 'message': 'Login successful' }) else: return Response( @@ -49,6 +355,166 @@ def login_user(request): status=status.HTTP_401_UNAUTHORIZED ) +@api_view(['POST']) +@permission_classes([AllowAny]) +def forgot_password(request): + serializer = ForgotPasswordSerializer(data=request.data) + + if serializer.is_valid(): + email = serializer.validated_data['email'] + + try: + user = CustomUser.objects.get(email=email) + + if not user.isVerified: + return Response({ + 'error': 'Email not verified', + 'message': 'Please verify your email address first.' + }, status=status.HTTP_400_BAD_REQUEST) + + if not user.is_active: + return Response({ + 'error': 'Account deactivated', + 'message': 'Your account has been deactivated.' + }, status=status.HTTP_400_BAD_REQUEST) + + otp = generate_otp() + user.forgot_password_otp = otp + user.forgot_password_otp_expiry = timezone.now() + timedelta(minutes=10) + user.save() + + user_name = f"{user.first_name} {user.last_name}".strip() or user.email + email_sent = send_otp_via_email(user.email, otp, user_name, 'password_reset') + + if not email_sent: + return Response({ + 'error': 'Failed to send OTP', + 'message': 'Please try again later.' + }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + return Response({ + 'message': 'Password reset OTP sent to your email', + 'otp_sent': True, + 'otp_expires_in': 10, + 'email': user.email + }, status=status.HTTP_200_OK) + + except CustomUser.DoesNotExist: + return Response({ + 'message': 'If the email exists, a password reset OTP has been sent.' + }, status=status.HTTP_200_OK) + + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + +@api_view(['POST']) +@permission_classes([AllowAny]) +def verify_password_reset_otp(request): + serializer = VerifyPasswordResetOTPSerializer(data=request.data) + + if serializer.is_valid(): + email = serializer.validated_data['email'] + otp = serializer.validated_data['otp'] + + try: + user = CustomUser.objects.get(email=email) + + if (user.forgot_password_otp == otp and + not is_otp_expired(user.forgot_password_otp_expiry)): + + return Response({ + 'message': 'OTP verified successfully', + 'verified': True, + 'email': user.email + }, status=status.HTTP_200_OK) + else: + return Response({ + 'error': 'Invalid or expired OTP' + }, status=status.HTTP_400_BAD_REQUEST) + + except CustomUser.DoesNotExist: + return Response({ + 'error': 'User not found' + }, status=status.HTTP_404_NOT_FOUND) + + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + +@api_view(['POST']) +@permission_classes([AllowAny]) +def reset_password(request): + serializer = ResetPasswordSerializer(data=request.data) + + if serializer.is_valid(): + email = serializer.validated_data['email'] + otp = serializer.validated_data['otp'] + new_password = serializer.validated_data['new_password'] + + try: + user = CustomUser.objects.get(email=email) + + if (user.forgot_password_otp == otp and + not is_otp_expired(user.forgot_password_otp_expiry)): + + # Set new password + user.set_password(new_password) + + user.forgot_password_otp = None + user.forgot_password_otp_expiry = None + user.save() + + return Response({ + 'message': 'Password reset successfully', + 'success': True + }, status=status.HTTP_200_OK) + else: + return Response({ + 'error': 'Invalid or expired OTP' + }, status=status.HTTP_400_BAD_REQUEST) + + except CustomUser.DoesNotExist: + return Response({ + 'error': 'User not found' + }, status=status.HTTP_404_NOT_FOUND) + + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + +@api_view(['POST']) +@permission_classes([AllowAny]) +def resend_password_reset_otp(request): + serializer = ForgotPasswordSerializer(data=request.data) + + if serializer.is_valid(): + email = serializer.validated_data['email'] + + try: + user = CustomUser.objects.get(email=email) + + otp = generate_otp() + user.forgot_password_otp = otp + user.forgot_password_otp_expiry = timezone.now() + timedelta(minutes=10) + user.save() + + user_name = f"{user.first_name} {user.last_name}".strip() or user.email + email_sent = send_otp_via_email(user.email, otp, user_name, 'password_reset') + + if not email_sent: + return Response({ + 'error': 'Failed to send OTP', + 'message': 'Please try again later.' + }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + return Response({ + 'message': 'Password reset OTP resent to your email', + 'otp_sent': True, + 'otp_expires_in': 10 + }, status=status.HTTP_200_OK) + + except CustomUser.DoesNotExist: + return Response({ + 'message': 'If the email exists, a password reset OTP has been sent.' + }, status=status.HTTP_200_OK) + + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + @api_view(['GET']) @permission_classes([IsAuthenticated]) def get_user_profile(request):