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 %}
+
+
+
+
⏰ 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.
+
+
+
+
+
+
+
+
+{% 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 %}
+
+
+
+
+ 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 %}
+
+
+
+
+
+
+
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:
+
+
+
+
+
+
+ "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
+
+
+
+
+
+
+
+
+
+
Hello {{ user_name }},
+
+ Thank you for registering with us! To complete your registration and
+ secure your account, please use the following verification code:
+
+
+
+
+
+
+ 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
+
+
+
+
+
+
+
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:
+
+
+
+
+
+ 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 %}
+
+
+
+
+ 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:
+
+
+
+
+
+ "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 %}
+
+
+
+
+ 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
+
+
+
+ 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 %}
+
+
+
+
+
+
+
+
+
+
📋 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.
+
+
+
+
+
+{% 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):