Compare commits
2 Commits
c2015d5ad0
...
9d9858ef69
| Author | SHA1 | Date | |
|---|---|---|---|
| 9d9858ef69 | |||
| f06b5120e9 |
4
.gitignore
vendored
4
.gitignore
vendored
@ -18,8 +18,6 @@ media
|
|||||||
*.py[cod]
|
*.py[cod]
|
||||||
*$py.class
|
*$py.class
|
||||||
|
|
||||||
meetings
|
|
||||||
|
|
||||||
# C extensions
|
# C extensions
|
||||||
*.so
|
*.so
|
||||||
|
|
||||||
@ -121,6 +119,8 @@ ipython_config.py
|
|||||||
# https://pdm.fming.dev/#use-with-ide
|
# https://pdm.fming.dev/#use-with-ide
|
||||||
.pdm.toml
|
.pdm.toml
|
||||||
|
|
||||||
|
meetings
|
||||||
|
|
||||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||||
__pypackages__/
|
__pypackages__/
|
||||||
|
|
||||||
|
|||||||
@ -46,7 +46,9 @@ ROOT_URLCONF = 'booking_system.urls'
|
|||||||
TEMPLATES = [
|
TEMPLATES = [
|
||||||
{
|
{
|
||||||
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||||
'DIRS': [],
|
'DIRS': [
|
||||||
|
os.path.join(BASE_DIR, 'templates'),
|
||||||
|
],
|
||||||
'APP_DIRS': True,
|
'APP_DIRS': True,
|
||||||
'OPTIONS': {
|
'OPTIONS': {
|
||||||
'context_processors': [
|
'context_processors': [
|
||||||
@ -95,12 +97,35 @@ SIMPLE_JWT = {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# HIPAA Email Configuration
|
||||||
|
EMAIL_ENCRYPTION_KEY = os.getenv('EMAIL_ENCRYPTION_KEY')
|
||||||
|
|
||||||
# Stripe Configuration
|
# Stripe Configuration
|
||||||
STRIPE_PUBLISHABLE_KEY = os.getenv('STRIPE_PUBLISHABLE_KEY')
|
STRIPE_PUBLISHABLE_KEY = os.getenv('STRIPE_PUBLISHABLE_KEY')
|
||||||
STRIPE_SECRET_KEY = os.getenv('STRIPE_SECRET_KEY')
|
STRIPE_SECRET_KEY = os.getenv('STRIPE_SECRET_KEY')
|
||||||
STRIPE_WEBHOOK_SECRET = os.getenv('STRIPE_WEBHOOK_SECRET')
|
STRIPE_WEBHOOK_SECRET = os.getenv('STRIPE_WEBHOOK_SECRET')
|
||||||
|
|
||||||
|
|
||||||
|
# HIPAA Compliance Settings
|
||||||
|
HIPAA_EMAIL_CONFIG = {
|
||||||
|
'ENCRYPTION_STANDARD': 'AES-256',
|
||||||
|
'REQUIRE_SECURE_PORTAL': True,
|
||||||
|
'AUDIT_RETENTION_DAYS': 365 * 6,
|
||||||
|
'AUTO_DELETE_UNREAD_DAYS': 30,
|
||||||
|
'REQUIRE_BAA': True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Secure Portal URL
|
||||||
|
SECURE_PORTAL_URL = os.getenv('SECURE_PORTAL_URL', 'https://secure.yourdomain.com')
|
||||||
|
|
||||||
|
# Business Associate Agreement Verification
|
||||||
|
BAA_VERIFICATION = {
|
||||||
|
'EMAIL_PROVIDER': os.getenv('EMAIL_PROVIDER'),
|
||||||
|
'BAA_SIGNED': os.getenv('BAA_SIGNED', 'False').lower() == 'true',
|
||||||
|
'BAA_EXPIRY': os.getenv('BAA_EXPIRY'),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# Jitsi Configuration
|
# Jitsi Configuration
|
||||||
JITSI_BASE_URL = os.getenv('JITSI_BASE_URL', 'https://meet.jit.si')
|
JITSI_BASE_URL = os.getenv('JITSI_BASE_URL', 'https://meet.jit.si')
|
||||||
|
|||||||
BIN
requirements.txt
BIN
requirements.txt
Binary file not shown.
100
templates/emails/admin_booking_notification.html
Normal file
100
templates/emails/admin_booking_notification.html
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
{% extends "emails/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}New Therapy Booking Request - Action Required{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="email-header" style="background: linear-gradient(135deg, #dc2626, #ea580c);">
|
||||||
|
<h1>📋 NEW THERAPY BOOKING REQUEST</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="email-body">
|
||||||
|
<div class="urgent-badge">⏰ ACTION REQUIRED - Please respond within 24 hours</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2 class="section-title">Patient Information</h2>
|
||||||
|
<div class="info-card">
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">Full Name:</span>
|
||||||
|
<span class="info-value">{{ booking.full_name }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">Email:</span>
|
||||||
|
<span class="info-value">{{ booking.email }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">Phone:</span>
|
||||||
|
<span class="info-value">{{ booking.phone }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">Submitted:</span>
|
||||||
|
<span class="info-value">{{ booking.created_at|date:"F d, Y" }} at {{ booking.created_at|time:"g:i A" }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2 class="section-title">Appointment Details</h2>
|
||||||
|
<div class="info-card">
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">Appointment Type:</span>
|
||||||
|
<span class="info-value">{{ booking.get_appointment_type_display }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">Preferred Date:</span>
|
||||||
|
<span class="info-value">{{ booking.preferred_date|date:"l, F d, Y" }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">Preferred Time:</span>
|
||||||
|
<span class="info-value">{{ booking.preferred_time }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">Session Fee:</span>
|
||||||
|
<span class="info-value">${{ booking.amount }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if booking.additional_message %}
|
||||||
|
<div class="section">
|
||||||
|
<h2 class="section-title">Patient's Message</h2>
|
||||||
|
<div class="info-card">
|
||||||
|
<div style="background: white; padding: 16px; border-radius: 6px; border-left: 4px solid #10b981;">
|
||||||
|
{{ booking.additional_message }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2 class="section-title">Required Actions</h2>
|
||||||
|
<div class="steps">
|
||||||
|
<div class="step">
|
||||||
|
<strong>Review Patient Information</strong><br>
|
||||||
|
Assess clinical appropriateness and availability.
|
||||||
|
</div>
|
||||||
|
<div class="step">
|
||||||
|
<strong>Contact Patient</strong><br>
|
||||||
|
Reach out within 24 hours to confirm appointment details.
|
||||||
|
</div>
|
||||||
|
<div class="step">
|
||||||
|
<strong>Confirm Booking</strong><br>
|
||||||
|
Update booking status and send confirmation email.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="text-align: center; margin: 30px 0;">
|
||||||
|
<a href="https://attunehearttherapy.com/admin" class="button" style="background: linear-gradient(135deg, #dc2626, #ea580c);">
|
||||||
|
📊 Manage This Booking
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<p><strong>Attune Heart Therapy - Admin Portal</strong></p>
|
||||||
|
<div class="contact-info">
|
||||||
|
Booking ID: {{ booking.id }}<br>
|
||||||
|
Received: {{ booking.created_at|date:"Y-m-d H:i" }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
196
templates/emails/base.html
Normal file
196
templates/emails/base.html
Normal file
@ -0,0 +1,196 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{% block title %}Attune Heart Therapy{% endblock %}</title>
|
||||||
|
<style>
|
||||||
|
/* Reset styles for email compatibility */
|
||||||
|
body, table, td, div, p, a {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
font-size: 100%;
|
||||||
|
font: inherit;
|
||||||
|
vertical-align: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #333333;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
margin: 0;
|
||||||
|
padding: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.email-container {
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.email-header {
|
||||||
|
background: linear-gradient(135deg, #ec4899, #8b5cf6);
|
||||||
|
color: white;
|
||||||
|
padding: 30px 40px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.email-header h1 {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.email-body {
|
||||||
|
padding: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.greeting {
|
||||||
|
font-size: 18px;
|
||||||
|
color: #374151;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2937;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
border-bottom: 2px solid #f3f4f6;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-card {
|
||||||
|
background: #f8fafc;
|
||||||
|
border-left: 4px solid #8b5cf6;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-item {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #4b5563;
|
||||||
|
min-width: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-value {
|
||||||
|
color: #1f2937;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
display: inline-block;
|
||||||
|
background: linear-gradient(135deg, #ec4899, #8b5cf6);
|
||||||
|
color: white;
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 14px 32px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: center;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.steps {
|
||||||
|
counter-reset: step-counter;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding-left: 40px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step:before {
|
||||||
|
counter-increment: step-counter;
|
||||||
|
content: counter(step-counter);
|
||||||
|
background: #8b5cf6;
|
||||||
|
color: white;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.urgent-badge {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #dc2626;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
display: inline-block;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
background: #f8fafc;
|
||||||
|
padding: 30px 40px;
|
||||||
|
text-align: center;
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer p {
|
||||||
|
color: #6b7280;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-info {
|
||||||
|
color: #4b5563;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive design */
|
||||||
|
@media only screen and (max-width: 600px) {
|
||||||
|
.email-body {
|
||||||
|
padding: 30px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.email-header {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.email-header h1 {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-item {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-label {
|
||||||
|
min-width: auto;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="email-container">
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
145
templates/emails/booking_confirmed.html
Normal file
145
templates/emails/booking_confirmed.html
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
{% extends "emails/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Appointment Confirmed - Attune Heart Therapy{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="email-header">
|
||||||
|
<h1>✅ Your Appointment is Confirmed!</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="email-body">
|
||||||
|
<div class="greeting">
|
||||||
|
Dear <strong>{{ booking.full_name }}</strong>,
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<p>We're delighted to confirm your <strong>{{ booking.get_appointment_type_display }}</strong> appointment. Your healing journey begins now, and we're honored to walk this path with you.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2 class="section-title">Appointment Details</h2>
|
||||||
|
<div class="info-card">
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">Appointment Type:</span>
|
||||||
|
<span class="info-value">{{ booking.get_appointment_type_display }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">Date & Time:</span>
|
||||||
|
<span class="info-value">{{ booking.confirmed_datetime|date:"l, F d, Y" }} at {{ booking.confirmed_datetime|time:"g:i A" }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">Duration:</span>
|
||||||
|
<span class="info-value">
|
||||||
|
{% if booking.appointment_type == 'initial-consultation' %}90 minutes
|
||||||
|
{% elif booking.appointment_type == 'individual-therapy' %}60 minutes
|
||||||
|
{% elif booking.appointment_type == 'family-therapy' %}90 minutes
|
||||||
|
{% elif booking.appointment_type == 'couples-therapy' %}75 minutes
|
||||||
|
{% elif booking.appointment_type == 'group-therapy' %}90 minutes
|
||||||
|
{% elif booking.appointment_type == 'follow-up' %}45 minutes
|
||||||
|
{% else %}60 minutes{% endif %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">Therapist:</span>
|
||||||
|
<span class="info-value">{{ booking.assigned_therapist.get_full_name }}</span>
|
||||||
|
</div>
|
||||||
|
{% if booking.payment_status == 'paid' %}
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">Payment Status:</span>
|
||||||
|
<span class="info-value" style="color: #10b981; font-weight: 600;">✅ Paid</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2 class="section-title">Join Your Session</h2>
|
||||||
|
<div class="info-card" style="background: linear-gradient(135deg, #f0f9ff, #e0f2fe); border-left-color: #0ea5e9;">
|
||||||
|
<div style="text-align: center; padding: 10px 0;">
|
||||||
|
<div style="font-size: 16px; font-weight: 600; color: #0369a1; margin-bottom: 15px;">
|
||||||
|
📅 Secure Video Session
|
||||||
|
</div>
|
||||||
|
<a href="{{ booking.jitsi_meet_url }}" class="button" style="font-size: 16px; padding: 16px 40px;">
|
||||||
|
🎥 Join Video Session
|
||||||
|
</a>
|
||||||
|
<div style="margin-top: 15px; font-size: 14px; color: #64748b;">
|
||||||
|
Or copy this link:<br>
|
||||||
|
<span style="word-break: break-all; color: #0ea5e9;">{{ booking.jitsi_meet_url }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2 class="section-title">Preparing for Your Session</h2>
|
||||||
|
<div class="steps">
|
||||||
|
<div class="step">
|
||||||
|
<strong>Test Your Technology</strong><br>
|
||||||
|
Please test your camera, microphone, and internet connection before the session.
|
||||||
|
</div>
|
||||||
|
<div class="step">
|
||||||
|
<strong>Find a Quiet Space</strong><br>
|
||||||
|
Choose a private, comfortable location where you won't be interrupted.
|
||||||
|
</div>
|
||||||
|
<div class="step">
|
||||||
|
<strong>Join Early</strong><br>
|
||||||
|
Please join 5-10 minutes before your scheduled time to ensure everything is working.
|
||||||
|
</div>
|
||||||
|
<div class="step">
|
||||||
|
<strong>Browser Recommendation</strong><br>
|
||||||
|
Use Chrome, Firefox, or Safari for the best video experience.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if booking.payment_status != 'paid' %}
|
||||||
|
<div class="section">
|
||||||
|
<h2 class="section-title">Payment Information</h2>
|
||||||
|
<div class="info-card" style="background: #fffbeb; border-left-color: #f59e0b;">
|
||||||
|
<div style="text-align: center;">
|
||||||
|
<div style="color: #d97706; font-weight: 600; margin-bottom: 10px;">
|
||||||
|
💳 Payment Required
|
||||||
|
</div>
|
||||||
|
<p>Your session fee of <strong>${{ booking.amount }}</strong> is pending. Please complete your payment before the session.</p>
|
||||||
|
<a href="https://attunehearttherapy.com/payment/{{ booking.id }}" class="button" style="background: linear-gradient(135deg, #f59e0b, #d97706);">
|
||||||
|
Complete Payment
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2 class="section-title">Need to Reschedule?</h2>
|
||||||
|
<p>If you need to reschedule or cancel your appointment, please contact us at least 24 hours in advance:</p>
|
||||||
|
<div style="text-align: center; margin: 20px 0;">
|
||||||
|
<a href="tel:+19548073027" class="button" style="background: linear-gradient(135deg, #64748b, #475569);">
|
||||||
|
📞 Call (954) 807-3027
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<div style="background: #f0fdf4; padding: 20px; border-radius: 8px; border-left: 4px solid #10b981;">
|
||||||
|
<p style="color: #065f46; font-style: italic; margin: 0;">
|
||||||
|
"The privilege of a lifetime is to become who you truly are."<br>
|
||||||
|
<span style="font-size: 14px;">- Carl Jung</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<p><strong>Attune Heart Therapy</strong></p>
|
||||||
|
<p>Compassionate Care for Your Healing Journey</p>
|
||||||
|
<div class="contact-info">
|
||||||
|
📞 (954) 807-3027<br>
|
||||||
|
✉️ hello@attunehearttherapy.com<br>
|
||||||
|
🌐 attunehearttherapy.com
|
||||||
|
</div>
|
||||||
|
<p style="font-size: 12px; color: #9ca3af; margin-top: 15px;">
|
||||||
|
Confirmation ID: {{ booking.id }}<br>
|
||||||
|
Sent: {{ booking.confirmed_datetime|date:"Y-m-d H:i" }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
179
templates/emails/otp_verification.html
Normal file
179
templates/emails/otp_verification.html
Normal file
@ -0,0 +1,179 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Email Verification</title>
|
||||||
|
<style>
|
||||||
|
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap");
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI",
|
||||||
|
Roboto, sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #333;
|
||||||
|
background-color: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.email-container {
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1),
|
||||||
|
0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.email-header {
|
||||||
|
background-image: linear-gradient(to right, #e11d48, #db2777, #f97316);
|
||||||
|
padding: 40px 30px;
|
||||||
|
text-align: center;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.email-header h1 {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.email-header p {
|
||||||
|
font-size: 16px;
|
||||||
|
opacity: 0.9;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.email-body {
|
||||||
|
padding: 40px 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.greeting {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2937;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.otp-container {
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 2px dashed #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 30px;
|
||||||
|
text-align: center;
|
||||||
|
margin: 30px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.otp-code {
|
||||||
|
font-size: 42px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1f2937;
|
||||||
|
letter-spacing: 8px;
|
||||||
|
font-family: "Monaco", "Menlo", "Ubuntu Mono", monospace;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.email-footer {
|
||||||
|
background-image: linear-gradient(to right, #e11d48, #db2777, #f97316);
|
||||||
|
padding: 30px;
|
||||||
|
text-align: center;
|
||||||
|
border-top: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.company-name {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-info {
|
||||||
|
color: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 15px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copyright {
|
||||||
|
color: #fff;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.email-container {
|
||||||
|
margin: 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.email-header,
|
||||||
|
.email-body,
|
||||||
|
.email-footer {
|
||||||
|
padding: 25px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.otp-code {
|
||||||
|
font-size: 32px;
|
||||||
|
letter-spacing: 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="email-container">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="email-header">
|
||||||
|
<h1>Verify Your Email Address</h1>
|
||||||
|
<p>Secure your account with one-time password</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Body -->
|
||||||
|
<div class="email-body">
|
||||||
|
<p class="greeting">Hello {{ user_name }},</p>
|
||||||
|
<p>
|
||||||
|
Thank you for registering with us! To complete your registration and
|
||||||
|
secure your account, please use the following verification code:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- OTP Display -->
|
||||||
|
<div class="otp-container">
|
||||||
|
<div class="otp-code">{{ otp }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<strong>Important:</strong> This code will expire in
|
||||||
|
<strong>{{ expiry_minutes }} minutes</strong> for security reasons.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
If you didn't request this code, please ignore this email or contact
|
||||||
|
our support team immediately.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div class="email-footer">
|
||||||
|
<div class="company-name">{{ company_name }}</div>
|
||||||
|
<p class="support-info">
|
||||||
|
Need help? Contact our support team at
|
||||||
|
<a
|
||||||
|
href="mailto:{{ support_email }}"
|
||||||
|
style="color: #fff; text-decoration: none"
|
||||||
|
>{{ support_email }}</a
|
||||||
|
>
|
||||||
|
</p>
|
||||||
|
<p class="copyright">
|
||||||
|
© {{ current_year }} {{ company_name }}. All rights reserved.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
170
templates/emails/password_reset_otp.html
Normal file
170
templates/emails/password_reset_otp.html
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Password Reset</title>
|
||||||
|
<style>
|
||||||
|
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap");
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI",
|
||||||
|
Roboto, sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #333;
|
||||||
|
background-color: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.email-container {
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1),
|
||||||
|
0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.email-header {
|
||||||
|
background-image: linear-gradient(to right, #e11d48, #db2777, #f97316);
|
||||||
|
padding: 40px 30px;
|
||||||
|
text-align: center;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.email-header h1 {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.email-header p {
|
||||||
|
font-size: 16px;
|
||||||
|
opacity: 0.9;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.email-body {
|
||||||
|
padding: 40px 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.greeting {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2937;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.otp-container {
|
||||||
|
background: #fff5f5;
|
||||||
|
border: 2px dashed #fed7d7;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 30px;
|
||||||
|
text-align: center;
|
||||||
|
margin: 30px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.otp-code {
|
||||||
|
font-size: 42px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #c53030;
|
||||||
|
letter-spacing: 8px;
|
||||||
|
font-family: "Monaco", "Menlo", "Ubuntu Mono", monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.email-footer {
|
||||||
|
background-image: linear-gradient(to right, #e11d48, #db2777, #f97316);
|
||||||
|
padding: 30px;
|
||||||
|
text-align: center;
|
||||||
|
border-top: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.company-name {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.support-info {
|
||||||
|
color: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 15px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copyright {
|
||||||
|
color: #fff;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.email-container {
|
||||||
|
margin: 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.email-header,
|
||||||
|
.email-body,
|
||||||
|
.email-footer {
|
||||||
|
padding: 25px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.otp-code {
|
||||||
|
font-size: 32px;
|
||||||
|
letter-spacing: 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="email-container">
|
||||||
|
<div class="email-header">
|
||||||
|
<h1>Password Reset Request</h1>
|
||||||
|
<p>Secure your account with verification code</p>
|
||||||
|
</div>
|
||||||
|
<div class="email-body">
|
||||||
|
<p class="greeting">Hello {{ user_name }},</p>
|
||||||
|
<p>
|
||||||
|
We received a request to reset your password for your account. Use the
|
||||||
|
verification code below to proceed with resetting your password:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="otp-container">
|
||||||
|
<div class="otp-code">{{ otp }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="expiry-notice">
|
||||||
|
<strong> Important:</strong> This code will expire in
|
||||||
|
<strong>{{ expiry_minutes }} minutes</strong> for security reasons.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<strong>Note:</strong> This password reset request was initiated from
|
||||||
|
our system. If this wasn't you, your account might be at risk.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="email-footer">
|
||||||
|
<div class="company-name">{{ company_name }}</div>
|
||||||
|
<p class="support-info">
|
||||||
|
Need help? Contact our support team at
|
||||||
|
<a
|
||||||
|
href="mailto:{{ support_email }}"
|
||||||
|
style="color: #fff; text-decoration: none"
|
||||||
|
>{{ support_email }}</a
|
||||||
|
>
|
||||||
|
</p>
|
||||||
|
<p class="copyright">
|
||||||
|
© {{ current_year }} {{ company_name }}. All rights reserved.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
86
templates/emails/patient_booking_confirmation.html
Normal file
86
templates/emails/patient_booking_confirmation.html
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
{% extends "emails/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Booking Request Received - Attune Heart Therapy{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="email-header">
|
||||||
|
<h1>🎉 Thank You for Your Booking Request!</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="email-body">
|
||||||
|
<div class="greeting">
|
||||||
|
Dear <strong>{{ booking.full_name }}</strong>,
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<p>We have received your request for a <strong>{{ booking.get_appointment_type_display }}</strong> appointment and we're excited to support you on your healing journey.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2 class="section-title">Your Request Details</h2>
|
||||||
|
<div class="info-card">
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">Appointment Type:</span>
|
||||||
|
<span class="info-value">{{ booking.get_appointment_type_display }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">Preferred Date:</span>
|
||||||
|
<span class="info-value">{{ booking.preferred_date|date:"l, F d, Y" }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">Preferred Time:</span>
|
||||||
|
<span class="info-value">{{ booking.preferred_time }}</span>
|
||||||
|
</div>
|
||||||
|
{% if booking.additional_message %}
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">Your Message:</span>
|
||||||
|
<span class="info-value">{{ booking.additional_message }}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2 class="section-title">What Happens Next?</h2>
|
||||||
|
<div class="steps">
|
||||||
|
<div class="step">
|
||||||
|
<strong>Review Process</strong><br>
|
||||||
|
Our clinical team will review your request to ensure we're the right fit for your needs.
|
||||||
|
</div>
|
||||||
|
<div class="step">
|
||||||
|
<strong>Confirmation</strong><br>
|
||||||
|
We'll contact you within 24 hours to confirm your appointment details.
|
||||||
|
</div>
|
||||||
|
<div class="step">
|
||||||
|
<strong>Session Preparation</strong><br>
|
||||||
|
You'll receive a confirmed date, time, and secure video meeting link.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2 class="section-title">Need Immediate Assistance?</h2>
|
||||||
|
<p>If you have any questions or need to modify your request, please don't hesitate to contact us:</p>
|
||||||
|
<div style="text-align: center; margin: 25px 0;">
|
||||||
|
<a href="tel:+19548073027" class="button">📞 Call Us: (954) 807-3027</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<p style="color: #6b7280; font-style: italic;">
|
||||||
|
"The journey of a thousand miles begins with a single step."<br>
|
||||||
|
- Lao Tzu
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<p><strong>Attune Heart Therapy</strong></p>
|
||||||
|
<p>Healing Hearts, Transforming Lives</p>
|
||||||
|
<div class="contact-info">
|
||||||
|
📞 (954) 807-3027<br>
|
||||||
|
✉️ hello@attunehearttherapy.com<br>
|
||||||
|
🌐 attunehearttherapy.com
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
112
templates/emails/payment_confirmed.html
Normal file
112
templates/emails/payment_confirmed.html
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
{% extends "emails/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Payment Confirmed - Attune Heart Therapy{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="email-header" style="background: linear-gradient(135deg, #10b981, #059669);">
|
||||||
|
<h1>💳 Payment Confirmed!</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="email-body">
|
||||||
|
<div class="greeting">
|
||||||
|
Dear <strong>{{ booking.full_name }}</strong>,
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<p>Thank you for your payment! Your <strong>{{ booking.get_appointment_type_display }}</strong> appointment is now fully confirmed and we're looking forward to our session.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2 class="section-title">Payment Details</h2>
|
||||||
|
<div class="info-card" style="background: linear-gradient(135deg, #f0fdf4, #dcfce7); border-left-color: #10b981;">
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">Amount Paid:</span>
|
||||||
|
<span class="info-value" style="color: #059669; font-size: 18px; font-weight: 700;">${{ booking.amount }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">Payment Date:</span>
|
||||||
|
<span class="info-value">{{ booking.paid_at|date:"F d, Y" }} at {{ booking.paid_at|time:"g:i A" }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">Appointment:</span>
|
||||||
|
<span class="info-value">{{ booking.get_appointment_type_display }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">Session Date:</span>
|
||||||
|
<span class="info-value">{{ booking.confirmed_datetime|date:"l, F d, Y" }} at {{ booking.confirmed_datetime|time:"g:i A" }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2 class="section-title">Your Session Details</h2>
|
||||||
|
<div class="info-card">
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">Video Meeting:</span>
|
||||||
|
<span class="info-value">
|
||||||
|
<a href="{{ booking.jitsi_meet_url }}" style="color: #0ea5e9; text-decoration: none;">
|
||||||
|
{{ booking.jitsi_meet_url }}
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">Therapist:</span>
|
||||||
|
<span class="info-value">{{ booking.assigned_therapist.get_full_name }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">Duration:</span>
|
||||||
|
<span class="info-value">
|
||||||
|
{% if booking.appointment_type == 'initial-consultation' %}90 minutes
|
||||||
|
{% elif booking.appointment_type == 'individual-therapy' %}60 minutes
|
||||||
|
{% elif booking.appointment_type == 'family-therapy' %}90 minutes
|
||||||
|
{% elif booking.appointment_type == 'couples-therapy' %}75 minutes
|
||||||
|
{% elif booking.appointment_type == 'group-therapy' %}90 minutes
|
||||||
|
{% elif booking.appointment_type == 'follow-up' %}45 minutes
|
||||||
|
{% else %}60 minutes{% endif %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="text-align: center; margin: 30px 0;">
|
||||||
|
<a href="{{ booking.jitsi_meet_url }}" class="button" style="background: linear-gradient(135deg, #10b981, #059669);">
|
||||||
|
🎥 Join Video Session
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<div style="background: #f0f9ff; padding: 20px; border-radius: 8px; text-align: center;">
|
||||||
|
<h3 style="color: #0369a1; margin-bottom: 10px;">📋 Receipt</h3>
|
||||||
|
<p style="margin: 5px 0;">Payment ID: {{ booking.stripe_payment_intent_id|default:booking.id }}</p>
|
||||||
|
<p style="margin: 5px 0;">Date: {{ booking.paid_at|date:"Y-m-d" }}</p>
|
||||||
|
<p style="margin: 5px 0;">Amount: ${{ booking.amount }}</p>
|
||||||
|
<p style="margin: 5px 0; font-size: 12px; color: #64748b;">
|
||||||
|
This email serves as your receipt for tax purposes.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<p>If you have any questions about your payment or appointment, please don't hesitate to contact us.</p>
|
||||||
|
<div style="text-align: center; margin: 20px 0;">
|
||||||
|
<a href="tel:+19548073027" class="button" style="background: linear-gradient(135deg, #64748b, #475569);">
|
||||||
|
📞 Call (954) 807-3027
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<p><strong>Attune Heart Therapy</strong></p>
|
||||||
|
<p>Thank you for trusting us with your care</p>
|
||||||
|
<div class="contact-info">
|
||||||
|
📞 (954) 807-3027<br>
|
||||||
|
✉️ hello@attunehearttherapy.com<br>
|
||||||
|
🌐 attunehearttherapy.com
|
||||||
|
</div>
|
||||||
|
<p style="font-size: 12px; color: #9ca3af; margin-top: 15px;">
|
||||||
|
Payment ID: {{ booking.stripe_payment_intent_id|default:booking.id }}<br>
|
||||||
|
Processed: {{ booking.paid_at|date:"Y-m-d H:i" }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@ -1,4 +1,4 @@
|
|||||||
# Generated by Django 5.2.8 on 2025-11-13 00:35
|
# Generated by Django 5.2.8 on 2025-11-22 02:11
|
||||||
|
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
@ -25,6 +25,11 @@ class Migration(migrations.Migration):
|
|||||||
('is_staff', models.BooleanField(default=False)),
|
('is_staff', models.BooleanField(default=False)),
|
||||||
('is_superuser', models.BooleanField(default=False)),
|
('is_superuser', models.BooleanField(default=False)),
|
||||||
('is_active', models.BooleanField(default=True)),
|
('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)),
|
('phone_number', models.CharField(blank=True, max_length=20)),
|
||||||
('last_login', models.DateTimeField(auto_now=True)),
|
('last_login', models.DateTimeField(auto_now=True)),
|
||||||
('date_joined', models.DateTimeField(auto_now_add=True)),
|
('date_joined', models.DateTimeField(auto_now_add=True)),
|
||||||
|
|||||||
@ -9,6 +9,11 @@ class CustomUser(AbstractBaseUser, PermissionsMixin):
|
|||||||
is_staff = models.BooleanField(default=False)
|
is_staff = models.BooleanField(default=False)
|
||||||
is_superuser = models.BooleanField(default=False)
|
is_superuser = models.BooleanField(default=False)
|
||||||
is_active = models.BooleanField(default=True)
|
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)
|
phone_number = models.CharField(max_length=20, blank=True)
|
||||||
last_login = models.DateTimeField(auto_now=True)
|
last_login = models.DateTimeField(auto_now=True)
|
||||||
date_joined = models.DateTimeField(auto_now_add=True)
|
date_joined = models.DateTimeField(auto_now_add=True)
|
||||||
@ -21,6 +26,9 @@ class CustomUser(AbstractBaseUser, PermissionsMixin):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.email
|
return self.email
|
||||||
|
|
||||||
|
def get_full_name(self):
|
||||||
|
return f"{self.first_name} {self.last_name}"
|
||||||
|
|
||||||
class UserProfile(models.Model):
|
class UserProfile(models.Model):
|
||||||
user = models.OneToOneField(CustomUser, on_delete=models.CASCADE, related_name='profile')
|
user = models.OneToOneField(CustomUser, on_delete=models.CASCADE, related_name='profile')
|
||||||
bio = models.TextField(max_length=500, blank=True)
|
bio = models.TextField(max_length=500, blank=True)
|
||||||
|
|||||||
@ -10,15 +10,10 @@ class UserProfileSerializer(serializers.ModelSerializer):
|
|||||||
class UserRegistrationSerializer(serializers.ModelSerializer):
|
class UserRegistrationSerializer(serializers.ModelSerializer):
|
||||||
password = serializers.CharField(write_only=True, required=True, validators=[validate_password])
|
password = serializers.CharField(write_only=True, required=True, validators=[validate_password])
|
||||||
password2 = serializers.CharField(write_only=True, required=True)
|
password2 = serializers.CharField(write_only=True, required=True)
|
||||||
profile = UserProfileSerializer(read_only=True)
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = CustomUser
|
model = CustomUser
|
||||||
fields = ['email', 'password', 'password2', 'first_name', 'last_name', 'profile']
|
fields = ('email', 'first_name', 'last_name', 'phone_number', 'password', 'password2')
|
||||||
extra_kwargs = {
|
|
||||||
'first_name': {'required': True},
|
|
||||||
'last_name': {'required': True}
|
|
||||||
}
|
|
||||||
|
|
||||||
def validate(self, attrs):
|
def validate(self, attrs):
|
||||||
if attrs['password'] != attrs['password2']:
|
if attrs['password'] != attrs['password2']:
|
||||||
@ -27,17 +22,35 @@ class UserRegistrationSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
def create(self, validated_data):
|
def create(self, validated_data):
|
||||||
validated_data.pop('password2')
|
validated_data.pop('password2')
|
||||||
user = CustomUser.objects.create_user(
|
password = validated_data.pop('password')
|
||||||
email=validated_data['email'],
|
|
||||||
password=validated_data['password'],
|
user = CustomUser.objects.create_user(**validated_data)
|
||||||
first_name=validated_data['first_name'],
|
user.set_password(password)
|
||||||
last_name=validated_data['last_name'],
|
user.is_active = True
|
||||||
)
|
user.isVerified = False
|
||||||
|
user.save()
|
||||||
|
|
||||||
return user
|
return user
|
||||||
|
|
||||||
class UserSerializer(serializers.ModelSerializer):
|
class ForgotPasswordSerializer(serializers.Serializer):
|
||||||
profile = UserProfileSerializer(read_only=True)
|
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):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = CustomUser
|
model = CustomUser
|
||||||
fields = ['id', 'email', 'first_name', 'last_name', 'phone_number', 'profile']
|
fields = ('id', 'email', 'first_name', 'last_name', 'phone_number', 'isVerified', 'date_joined')
|
||||||
@ -3,8 +3,20 @@ from rest_framework_simplejwt.views import TokenRefreshView
|
|||||||
from . import views
|
from . import views
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
|
path('', views.api_root, name='api-root'),
|
||||||
|
|
||||||
path('register/', views.register_user, name='register'),
|
path('register/', views.register_user, name='register'),
|
||||||
path('login/', views.login_user, name='login'),
|
path('login/', views.login_user, name='login'),
|
||||||
|
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('token/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
|
||||||
path('profile/', views.get_user_profile, name='profile'),
|
path('profile/', views.get_user_profile, name='profile'),
|
||||||
path('profile/update/', views.update_user_profile, name='update_profile'),
|
path('profile/update/', views.update_user_profile, name='update_profile'),
|
||||||
|
|||||||
57
users/utils.py
Normal file
57
users/utils.py
Normal file
@ -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
|
||||||
478
users/views.py
478
users/views.py
@ -5,7 +5,191 @@ from rest_framework.permissions import AllowAny, IsAuthenticated
|
|||||||
from rest_framework_simplejwt.tokens import RefreshToken
|
from rest_framework_simplejwt.tokens import RefreshToken
|
||||||
from django.contrib.auth import authenticate
|
from django.contrib.auth import authenticate
|
||||||
from .models import CustomUser, UserProfile
|
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'])
|
@api_view(['POST'])
|
||||||
@permission_classes([AllowAny])
|
@permission_classes([AllowAny])
|
||||||
@ -14,20 +198,126 @@ def register_user(request):
|
|||||||
if serializer.is_valid():
|
if serializer.is_valid():
|
||||||
user = serializer.save()
|
user = serializer.save()
|
||||||
|
|
||||||
# Create user profile
|
|
||||||
UserProfile.objects.create(user=user)
|
UserProfile.objects.create(user=user)
|
||||||
|
|
||||||
# Generate tokens
|
otp = generate_otp()
|
||||||
refresh = RefreshToken.for_user(user)
|
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({
|
return Response({
|
||||||
'user': UserSerializer(user).data,
|
'user': UserSerializer(user).data,
|
||||||
'refresh': str(refresh),
|
'otp_sent': email_sent,
|
||||||
'access': str(refresh.access_token),
|
'otp_expires_in': 10
|
||||||
}, status=status.HTTP_201_CREATED)
|
}, status=status.HTTP_201_CREATED)
|
||||||
|
|
||||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
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'])
|
@api_view(['POST'])
|
||||||
@permission_classes([AllowAny])
|
@permission_classes([AllowAny])
|
||||||
def login_user(request):
|
def login_user(request):
|
||||||
@ -37,11 +327,27 @@ def login_user(request):
|
|||||||
user = authenticate(request, email=email, password=password)
|
user = authenticate(request, email=email, password=password)
|
||||||
|
|
||||||
if user is not None:
|
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)
|
refresh = RefreshToken.for_user(user)
|
||||||
|
|
||||||
return Response({
|
return Response({
|
||||||
'user': UserSerializer(user).data,
|
'user': UserSerializer(user).data,
|
||||||
'refresh': str(refresh),
|
'refresh': str(refresh),
|
||||||
'access': str(refresh.access_token),
|
'access': str(refresh.access_token),
|
||||||
|
'message': 'Login successful'
|
||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
return Response(
|
return Response(
|
||||||
@ -49,6 +355,166 @@ def login_user(request):
|
|||||||
status=status.HTTP_401_UNAUTHORIZED
|
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'])
|
@api_view(['GET'])
|
||||||
@permission_classes([IsAuthenticated])
|
@permission_classes([IsAuthenticated])
|
||||||
def get_user_profile(request):
|
def get_user_profile(request):
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user