Compare commits
No commits in common. "9d9858ef696d3d7dc6e1ac4d04fb83e143ee4b5a" and "c2015d5ad0d54056acd9b6fd2149afe6f89cb41f" have entirely different histories.
9d9858ef69
...
c2015d5ad0
4
.gitignore
vendored
4
.gitignore
vendored
@ -18,6 +18,8 @@ media
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
meetings
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
@ -119,8 +121,6 @@ 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__/
|
||||
|
||||
|
||||
@ -46,9 +46,7 @@ ROOT_URLCONF = 'booking_system.urls'
|
||||
TEMPLATES = [
|
||||
{
|
||||
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||
'DIRS': [
|
||||
os.path.join(BASE_DIR, 'templates'),
|
||||
],
|
||||
'DIRS': [],
|
||||
'APP_DIRS': True,
|
||||
'OPTIONS': {
|
||||
'context_processors': [
|
||||
@ -97,35 +95,12 @@ 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')
|
||||
|
||||
BIN
requirements.txt
BIN
requirements.txt
Binary file not shown.
@ -1,100 +0,0 @@
|
||||
{% extends "emails/base.html" %}
|
||||
|
||||
{% block title %}New Therapy Booking Request - Action Required{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="email-header" style="background: linear-gradient(135deg, #dc2626, #ea580c);">
|
||||
<h1>📋 NEW THERAPY BOOKING REQUEST</h1>
|
||||
</div>
|
||||
|
||||
<div class="email-body">
|
||||
<div class="urgent-badge">⏰ ACTION REQUIRED - Please respond within 24 hours</div>
|
||||
|
||||
<div class="section">
|
||||
<h2 class="section-title">Patient Information</h2>
|
||||
<div class="info-card">
|
||||
<div class="info-item">
|
||||
<span class="info-label">Full Name:</span>
|
||||
<span class="info-value">{{ booking.full_name }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">Email:</span>
|
||||
<span class="info-value">{{ booking.email }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">Phone:</span>
|
||||
<span class="info-value">{{ booking.phone }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">Submitted:</span>
|
||||
<span class="info-value">{{ booking.created_at|date:"F d, Y" }} at {{ booking.created_at|time:"g:i A" }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2 class="section-title">Appointment Details</h2>
|
||||
<div class="info-card">
|
||||
<div class="info-item">
|
||||
<span class="info-label">Appointment Type:</span>
|
||||
<span class="info-value">{{ booking.get_appointment_type_display }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">Preferred Date:</span>
|
||||
<span class="info-value">{{ booking.preferred_date|date:"l, F d, Y" }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">Preferred Time:</span>
|
||||
<span class="info-value">{{ booking.preferred_time }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">Session Fee:</span>
|
||||
<span class="info-value">${{ booking.amount }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if booking.additional_message %}
|
||||
<div class="section">
|
||||
<h2 class="section-title">Patient's Message</h2>
|
||||
<div class="info-card">
|
||||
<div style="background: white; padding: 16px; border-radius: 6px; border-left: 4px solid #10b981;">
|
||||
{{ booking.additional_message }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="section">
|
||||
<h2 class="section-title">Required Actions</h2>
|
||||
<div class="steps">
|
||||
<div class="step">
|
||||
<strong>Review Patient Information</strong><br>
|
||||
Assess clinical appropriateness and availability.
|
||||
</div>
|
||||
<div class="step">
|
||||
<strong>Contact Patient</strong><br>
|
||||
Reach out within 24 hours to confirm appointment details.
|
||||
</div>
|
||||
<div class="step">
|
||||
<strong>Confirm Booking</strong><br>
|
||||
Update booking status and send confirmation email.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="text-align: center; margin: 30px 0;">
|
||||
<a href="https://attunehearttherapy.com/admin" class="button" style="background: linear-gradient(135deg, #dc2626, #ea580c);">
|
||||
📊 Manage This Booking
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p><strong>Attune Heart Therapy - Admin Portal</strong></p>
|
||||
<div class="contact-info">
|
||||
Booking ID: {{ booking.id }}<br>
|
||||
Received: {{ booking.created_at|date:"Y-m-d H:i" }}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@ -1,196 +0,0 @@
|
||||
<!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>
|
||||
@ -1,145 +0,0 @@
|
||||
{% extends "emails/base.html" %}
|
||||
|
||||
{% block title %}Appointment Confirmed - Attune Heart Therapy{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="email-header">
|
||||
<h1>✅ Your Appointment is Confirmed!</h1>
|
||||
</div>
|
||||
|
||||
<div class="email-body">
|
||||
<div class="greeting">
|
||||
Dear <strong>{{ booking.full_name }}</strong>,
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<p>We're delighted to confirm your <strong>{{ booking.get_appointment_type_display }}</strong> appointment. Your healing journey begins now, and we're honored to walk this path with you.</p>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2 class="section-title">Appointment Details</h2>
|
||||
<div class="info-card">
|
||||
<div class="info-item">
|
||||
<span class="info-label">Appointment Type:</span>
|
||||
<span class="info-value">{{ booking.get_appointment_type_display }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">Date & Time:</span>
|
||||
<span class="info-value">{{ booking.confirmed_datetime|date:"l, F d, Y" }} at {{ booking.confirmed_datetime|time:"g:i A" }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">Duration:</span>
|
||||
<span class="info-value">
|
||||
{% if booking.appointment_type == 'initial-consultation' %}90 minutes
|
||||
{% elif booking.appointment_type == 'individual-therapy' %}60 minutes
|
||||
{% elif booking.appointment_type == 'family-therapy' %}90 minutes
|
||||
{% elif booking.appointment_type == 'couples-therapy' %}75 minutes
|
||||
{% elif booking.appointment_type == 'group-therapy' %}90 minutes
|
||||
{% elif booking.appointment_type == 'follow-up' %}45 minutes
|
||||
{% else %}60 minutes{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">Therapist:</span>
|
||||
<span class="info-value">{{ booking.assigned_therapist.get_full_name }}</span>
|
||||
</div>
|
||||
{% if booking.payment_status == 'paid' %}
|
||||
<div class="info-item">
|
||||
<span class="info-label">Payment Status:</span>
|
||||
<span class="info-value" style="color: #10b981; font-weight: 600;">✅ Paid</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2 class="section-title">Join Your Session</h2>
|
||||
<div class="info-card" style="background: linear-gradient(135deg, #f0f9ff, #e0f2fe); border-left-color: #0ea5e9;">
|
||||
<div style="text-align: center; padding: 10px 0;">
|
||||
<div style="font-size: 16px; font-weight: 600; color: #0369a1; margin-bottom: 15px;">
|
||||
📅 Secure Video Session
|
||||
</div>
|
||||
<a href="{{ booking.jitsi_meet_url }}" class="button" style="font-size: 16px; padding: 16px 40px;">
|
||||
🎥 Join Video Session
|
||||
</a>
|
||||
<div style="margin-top: 15px; font-size: 14px; color: #64748b;">
|
||||
Or copy this link:<br>
|
||||
<span style="word-break: break-all; color: #0ea5e9;">{{ booking.jitsi_meet_url }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2 class="section-title">Preparing for Your Session</h2>
|
||||
<div class="steps">
|
||||
<div class="step">
|
||||
<strong>Test Your Technology</strong><br>
|
||||
Please test your camera, microphone, and internet connection before the session.
|
||||
</div>
|
||||
<div class="step">
|
||||
<strong>Find a Quiet Space</strong><br>
|
||||
Choose a private, comfortable location where you won't be interrupted.
|
||||
</div>
|
||||
<div class="step">
|
||||
<strong>Join Early</strong><br>
|
||||
Please join 5-10 minutes before your scheduled time to ensure everything is working.
|
||||
</div>
|
||||
<div class="step">
|
||||
<strong>Browser Recommendation</strong><br>
|
||||
Use Chrome, Firefox, or Safari for the best video experience.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if booking.payment_status != 'paid' %}
|
||||
<div class="section">
|
||||
<h2 class="section-title">Payment Information</h2>
|
||||
<div class="info-card" style="background: #fffbeb; border-left-color: #f59e0b;">
|
||||
<div style="text-align: center;">
|
||||
<div style="color: #d97706; font-weight: 600; margin-bottom: 10px;">
|
||||
💳 Payment Required
|
||||
</div>
|
||||
<p>Your session fee of <strong>${{ booking.amount }}</strong> is pending. Please complete your payment before the session.</p>
|
||||
<a href="https://attunehearttherapy.com/payment/{{ booking.id }}" class="button" style="background: linear-gradient(135deg, #f59e0b, #d97706);">
|
||||
Complete Payment
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="section">
|
||||
<h2 class="section-title">Need to Reschedule?</h2>
|
||||
<p>If you need to reschedule or cancel your appointment, please contact us at least 24 hours in advance:</p>
|
||||
<div style="text-align: center; margin: 20px 0;">
|
||||
<a href="tel:+19548073027" class="button" style="background: linear-gradient(135deg, #64748b, #475569);">
|
||||
📞 Call (954) 807-3027
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div style="background: #f0fdf4; padding: 20px; border-radius: 8px; border-left: 4px solid #10b981;">
|
||||
<p style="color: #065f46; font-style: italic; margin: 0;">
|
||||
"The privilege of a lifetime is to become who you truly are."<br>
|
||||
<span style="font-size: 14px;">- Carl Jung</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p><strong>Attune Heart Therapy</strong></p>
|
||||
<p>Compassionate Care for Your Healing Journey</p>
|
||||
<div class="contact-info">
|
||||
📞 (954) 807-3027<br>
|
||||
✉️ hello@attunehearttherapy.com<br>
|
||||
🌐 attunehearttherapy.com
|
||||
</div>
|
||||
<p style="font-size: 12px; color: #9ca3af; margin-top: 15px;">
|
||||
Confirmation ID: {{ booking.id }}<br>
|
||||
Sent: {{ booking.confirmed_datetime|date:"Y-m-d H:i" }}
|
||||
</p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@ -1,179 +0,0 @@
|
||||
<!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>
|
||||
@ -1,170 +0,0 @@
|
||||
<!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>
|
||||
@ -1,86 +0,0 @@
|
||||
{% extends "emails/base.html" %}
|
||||
|
||||
{% block title %}Booking Request Received - Attune Heart Therapy{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="email-header">
|
||||
<h1>🎉 Thank You for Your Booking Request!</h1>
|
||||
</div>
|
||||
|
||||
<div class="email-body">
|
||||
<div class="greeting">
|
||||
Dear <strong>{{ booking.full_name }}</strong>,
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<p>We have received your request for a <strong>{{ booking.get_appointment_type_display }}</strong> appointment and we're excited to support you on your healing journey.</p>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2 class="section-title">Your Request Details</h2>
|
||||
<div class="info-card">
|
||||
<div class="info-item">
|
||||
<span class="info-label">Appointment Type:</span>
|
||||
<span class="info-value">{{ booking.get_appointment_type_display }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">Preferred Date:</span>
|
||||
<span class="info-value">{{ booking.preferred_date|date:"l, F d, Y" }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">Preferred Time:</span>
|
||||
<span class="info-value">{{ booking.preferred_time }}</span>
|
||||
</div>
|
||||
{% if booking.additional_message %}
|
||||
<div class="info-item">
|
||||
<span class="info-label">Your Message:</span>
|
||||
<span class="info-value">{{ booking.additional_message }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2 class="section-title">What Happens Next?</h2>
|
||||
<div class="steps">
|
||||
<div class="step">
|
||||
<strong>Review Process</strong><br>
|
||||
Our clinical team will review your request to ensure we're the right fit for your needs.
|
||||
</div>
|
||||
<div class="step">
|
||||
<strong>Confirmation</strong><br>
|
||||
We'll contact you within 24 hours to confirm your appointment details.
|
||||
</div>
|
||||
<div class="step">
|
||||
<strong>Session Preparation</strong><br>
|
||||
You'll receive a confirmed date, time, and secure video meeting link.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2 class="section-title">Need Immediate Assistance?</h2>
|
||||
<p>If you have any questions or need to modify your request, please don't hesitate to contact us:</p>
|
||||
<div style="text-align: center; margin: 25px 0;">
|
||||
<a href="tel:+19548073027" class="button">📞 Call Us: (954) 807-3027</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<p style="color: #6b7280; font-style: italic;">
|
||||
"The journey of a thousand miles begins with a single step."<br>
|
||||
- Lao Tzu
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p><strong>Attune Heart Therapy</strong></p>
|
||||
<p>Healing Hearts, Transforming Lives</p>
|
||||
<div class="contact-info">
|
||||
📞 (954) 807-3027<br>
|
||||
✉️ hello@attunehearttherapy.com<br>
|
||||
🌐 attunehearttherapy.com
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@ -1,112 +0,0 @@
|
||||
{% extends "emails/base.html" %}
|
||||
|
||||
{% block title %}Payment Confirmed - Attune Heart Therapy{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="email-header" style="background: linear-gradient(135deg, #10b981, #059669);">
|
||||
<h1>💳 Payment Confirmed!</h1>
|
||||
</div>
|
||||
|
||||
<div class="email-body">
|
||||
<div class="greeting">
|
||||
Dear <strong>{{ booking.full_name }}</strong>,
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<p>Thank you for your payment! Your <strong>{{ booking.get_appointment_type_display }}</strong> appointment is now fully confirmed and we're looking forward to our session.</p>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2 class="section-title">Payment Details</h2>
|
||||
<div class="info-card" style="background: linear-gradient(135deg, #f0fdf4, #dcfce7); border-left-color: #10b981;">
|
||||
<div class="info-item">
|
||||
<span class="info-label">Amount Paid:</span>
|
||||
<span class="info-value" style="color: #059669; font-size: 18px; font-weight: 700;">${{ booking.amount }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">Payment Date:</span>
|
||||
<span class="info-value">{{ booking.paid_at|date:"F d, Y" }} at {{ booking.paid_at|time:"g:i A" }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">Appointment:</span>
|
||||
<span class="info-value">{{ booking.get_appointment_type_display }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">Session Date:</span>
|
||||
<span class="info-value">{{ booking.confirmed_datetime|date:"l, F d, Y" }} at {{ booking.confirmed_datetime|time:"g:i A" }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2 class="section-title">Your Session Details</h2>
|
||||
<div class="info-card">
|
||||
<div class="info-item">
|
||||
<span class="info-label">Video Meeting:</span>
|
||||
<span class="info-value">
|
||||
<a href="{{ booking.jitsi_meet_url }}" style="color: #0ea5e9; text-decoration: none;">
|
||||
{{ booking.jitsi_meet_url }}
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">Therapist:</span>
|
||||
<span class="info-value">{{ booking.assigned_therapist.get_full_name }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">Duration:</span>
|
||||
<span class="info-value">
|
||||
{% if booking.appointment_type == 'initial-consultation' %}90 minutes
|
||||
{% elif booking.appointment_type == 'individual-therapy' %}60 minutes
|
||||
{% elif booking.appointment_type == 'family-therapy' %}90 minutes
|
||||
{% elif booking.appointment_type == 'couples-therapy' %}75 minutes
|
||||
{% elif booking.appointment_type == 'group-therapy' %}90 minutes
|
||||
{% elif booking.appointment_type == 'follow-up' %}45 minutes
|
||||
{% else %}60 minutes{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="text-align: center; margin: 30px 0;">
|
||||
<a href="{{ booking.jitsi_meet_url }}" class="button" style="background: linear-gradient(135deg, #10b981, #059669);">
|
||||
🎥 Join Video Session
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div style="background: #f0f9ff; padding: 20px; border-radius: 8px; text-align: center;">
|
||||
<h3 style="color: #0369a1; margin-bottom: 10px;">📋 Receipt</h3>
|
||||
<p style="margin: 5px 0;">Payment ID: {{ booking.stripe_payment_intent_id|default:booking.id }}</p>
|
||||
<p style="margin: 5px 0;">Date: {{ booking.paid_at|date:"Y-m-d" }}</p>
|
||||
<p style="margin: 5px 0;">Amount: ${{ booking.amount }}</p>
|
||||
<p style="margin: 5px 0; font-size: 12px; color: #64748b;">
|
||||
This email serves as your receipt for tax purposes.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<p>If you have any questions about your payment or appointment, please don't hesitate to contact us.</p>
|
||||
<div style="text-align: center; margin: 20px 0;">
|
||||
<a href="tel:+19548073027" class="button" style="background: linear-gradient(135deg, #64748b, #475569);">
|
||||
📞 Call (954) 807-3027
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p><strong>Attune Heart Therapy</strong></p>
|
||||
<p>Thank you for trusting us with your care</p>
|
||||
<div class="contact-info">
|
||||
📞 (954) 807-3027<br>
|
||||
✉️ hello@attunehearttherapy.com<br>
|
||||
🌐 attunehearttherapy.com
|
||||
</div>
|
||||
<p style="font-size: 12px; color: #9ca3af; margin-top: 15px;">
|
||||
Payment ID: {{ booking.stripe_payment_intent_id|default:booking.id }}<br>
|
||||
Processed: {{ booking.paid_at|date:"Y-m-d H:i" }}
|
||||
</p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@ -1,4 +1,4 @@
|
||||
# Generated by Django 5.2.8 on 2025-11-22 02:11
|
||||
# Generated by Django 5.2.8 on 2025-11-13 00:35
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
@ -25,11 +25,6 @@ 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)),
|
||||
|
||||
@ -9,11 +9,6 @@ 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)
|
||||
@ -25,9 +20,6 @@ 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')
|
||||
|
||||
@ -10,47 +10,34 @@ 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', 'first_name', 'last_name', 'phone_number', 'password', 'password2')
|
||||
|
||||
fields = ['email', 'password', 'password2', 'first_name', 'last_name', 'profile']
|
||||
extra_kwargs = {
|
||||
'first_name': {'required': True},
|
||||
'last_name': {'required': True}
|
||||
}
|
||||
|
||||
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')
|
||||
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()
|
||||
|
||||
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'],
|
||||
)
|
||||
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', 'isVerified', 'date_joined')
|
||||
fields = ['id', 'email', 'first_name', 'last_name', 'phone_number', 'profile']
|
||||
@ -3,20 +3,8 @@ 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'),
|
||||
|
||||
@ -1,57 +0,0 @@
|
||||
# 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,191 +5,7 @@ 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, 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'
|
||||
]
|
||||
})
|
||||
from .serializers import UserRegistrationSerializer, UserSerializer
|
||||
|
||||
@api_view(['POST'])
|
||||
@permission_classes([AllowAny])
|
||||
@ -198,126 +14,20 @@ def register_user(request):
|
||||
if serializer.is_valid():
|
||||
user = serializer.save()
|
||||
|
||||
# Create user profile
|
||||
UserProfile.objects.create(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)
|
||||
# Generate tokens
|
||||
refresh = RefreshToken.for_user(user)
|
||||
|
||||
return Response({
|
||||
'user': UserSerializer(user).data,
|
||||
'otp_sent': email_sent,
|
||||
'otp_expires_in': 10
|
||||
'refresh': str(refresh),
|
||||
'access': str(refresh.access_token),
|
||||
}, 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):
|
||||
@ -327,27 +37,11 @@ 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(
|
||||
@ -355,166 +49,6 @@ 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):
|
||||
|
||||
Loading…
Reference in New Issue
Block a user