agdar/CONSENT_EMAIL_SIGNING_IMPLEMENTATION.md
2025-11-02 14:35:35 +03:00

925 lines
30 KiB
Markdown

# Consent Email Signing Implementation Plan
**Date:** October 30, 2025
**Feature:** Email-based Consent Signing for Parents/Guardians
**Requirement:** Parents/guardians can sign consent forms via email without logging in
---
## Overview
Implement a secure, email-based consent signing workflow that allows parents/guardians to:
1. Receive consent forms via email
2. Review consent forms online
3. Sign consent forms electronically
4. Complete the process without creating an account or logging in
---
## Architecture
### Flow Diagram
```
Staff creates consent → System generates secure token → Email sent to parent
Parent clicks link
Public consent page
Parent reviews & signs
Consent recorded in system
Confirmation email sent
```
---
## Implementation Steps
### 1. Database Changes
#### Add ConsentToken Model (`core/models.py`)
```python
class ConsentToken(UUIDPrimaryKeyMixin, TimeStampedMixin):
"""
Secure token for email-based consent signing.
Allows parents/guardians to sign consent without logging in.
"""
consent = models.ForeignKey(
'Consent',
on_delete=models.CASCADE,
related_name='tokens',
verbose_name=_("Consent")
)
token = models.CharField(
max_length=64,
unique=True,
verbose_name=_("Token"),
help_text=_("Secure token for accessing consent form")
)
email = models.EmailField(
verbose_name=_("Email Address"),
help_text=_("Email address where consent link was sent")
)
expires_at = models.DateTimeField(
verbose_name=_("Expires At"),
help_text=_("Token expiration date/time")
)
used_at = models.DateTimeField(
null=True,
blank=True,
verbose_name=_("Used At"),
help_text=_("When the token was used to sign consent")
)
is_active = models.BooleanField(
default=True,
verbose_name=_("Is Active")
)
sent_by = models.ForeignKey(
'User',
on_delete=models.SET_NULL,
null=True,
related_name='sent_consent_tokens',
verbose_name=_("Sent By")
)
class Meta:
verbose_name = _("Consent Token")
verbose_name_plural = _("Consent Tokens")
ordering = ['-created_at']
indexes = [
models.Index(fields=['token']),
models.Index(fields=['email']),
models.Index(fields=['expires_at']),
]
def __str__(self):
return f"Token for {self.consent} - {self.email}"
def is_valid(self):
"""Check if token is still valid."""
from django.utils import timezone
return (
self.is_active and
not self.used_at and
self.expires_at > timezone.now()
)
def mark_as_used(self):
"""Mark token as used."""
from django.utils import timezone
self.used_at = timezone.now()
self.is_active = False
self.save()
@staticmethod
def generate_token():
"""Generate a secure random token."""
import secrets
return secrets.token_urlsafe(48)
```
#### Migration Command
```bash
python manage.py makemigrations core
python manage.py migrate
```
---
### 2. Service Layer
#### Add ConsentEmailService (`core/services.py`)
```python
class ConsentEmailService:
"""Service for email-based consent signing."""
@staticmethod
@transaction.atomic
def send_consent_for_signing(
consent: Consent,
email: str,
sent_by: User,
expiry_hours: int = 72
) -> ConsentToken:
"""
Send consent form to parent/guardian for signing.
Args:
consent: Consent instance to send
email: Email address to send to
sent_by: User sending the consent
expiry_hours: Hours until token expires (default 72)
Returns:
ConsentToken: Created token instance
"""
from django.utils import timezone
from datetime import timedelta
# Generate secure token
token_string = ConsentToken.generate_token()
# Calculate expiry
expires_at = timezone.now() + timedelta(hours=expiry_hours)
# Create token
token = ConsentToken.objects.create(
consent=consent,
token=token_string,
email=email,
expires_at=expires_at,
sent_by=sent_by
)
# Send email
ConsentEmailService._send_consent_email(token)
logger.info(
f"Consent signing link sent to {email} for consent {consent.id}"
)
return token
@staticmethod
def _send_consent_email(token: ConsentToken):
"""Send email with consent signing link."""
from django.core.mail import send_mail
from django.template.loader import render_to_string
from django.conf import settings
from django.urls import reverse
# Build signing URL
signing_url = settings.SITE_URL + reverse(
'core:consent_sign_public',
kwargs={'token': token.token}
)
# Prepare email context
context = {
'consent': token.consent,
'patient': token.consent.patient,
'signing_url': signing_url,
'expires_at': token.expires_at,
'clinic_name': token.consent.tenant.name,
}
# Render email templates
subject = f"Consent Form for {token.consent.patient.full_name_en}"
html_message = render_to_string(
'emails/consent_signing_request.html',
context
)
text_message = render_to_string(
'emails/consent_signing_request.txt',
context
)
# Send email
send_mail(
subject=subject,
message=text_message,
from_email=settings.DEFAULT_FROM_EMAIL,
recipient_list=[token.email],
html_message=html_message,
fail_silently=False,
)
@staticmethod
def verify_token(token_string: str) -> Tuple[bool, str, Optional[ConsentToken]]:
"""
Verify if token is valid.
Args:
token_string: Token string to verify
Returns:
Tuple[bool, str, Optional[ConsentToken]]: (is_valid, message, token)
"""
try:
token = ConsentToken.objects.select_related(
'consent', 'consent__patient'
).get(token=token_string)
if not token.is_valid():
if token.used_at:
return False, "This consent has already been signed.", None
elif token.expires_at < timezone.now():
return False, "This link has expired. Please request a new one.", None
else:
return False, "This link is no longer valid.", None
return True, "Token is valid.", token
except ConsentToken.DoesNotExist:
return False, "Invalid consent link.", None
@staticmethod
@transaction.atomic
def sign_consent_via_token(
token: ConsentToken,
signed_by_name: str,
signed_by_relationship: str,
signature_method: str,
signature_image=None,
signed_ip: str = None,
signed_user_agent: str = None
) -> Consent:
"""
Sign consent using email token.
Args:
token: ConsentToken instance
signed_by_name: Name of person signing
signed_by_relationship: Relationship to patient
signature_method: Method used for signature
signature_image: Optional signature image
signed_ip: IP address of signer
signed_user_agent: User agent of signer
Returns:
Consent: Signed consent instance
"""
# Sign the consent
consent = ConsentService.sign_consent(
consent=token.consent,
signed_by_name=signed_by_name,
signed_by_relationship=signed_by_relationship,
signature_method=signature_method,
signature_image=signature_image,
signed_ip=signed_ip,
signed_user_agent=signed_user_agent
)
# Mark token as used
token.mark_as_used()
# Send confirmation email
ConsentEmailService._send_confirmation_email(token, consent)
logger.info(
f"Consent {consent.id} signed via email token by {signed_by_name}"
)
return consent
@staticmethod
def _send_confirmation_email(token: ConsentToken, consent: Consent):
"""Send confirmation email after consent is signed."""
from django.core.mail import send_mail
from django.template.loader import render_to_string
from django.conf import settings
context = {
'consent': consent,
'patient': consent.patient,
'signed_by_name': consent.signed_by_name,
'signed_at': consent.signed_at,
'clinic_name': consent.tenant.name,
}
subject = f"Consent Form Signed - {consent.patient.full_name_en}"
html_message = render_to_string(
'emails/consent_signed_confirmation.html',
context
)
text_message = render_to_string(
'emails/consent_signed_confirmation.txt',
context
)
send_mail(
subject=subject,
message=text_message,
from_email=settings.DEFAULT_FROM_EMAIL,
recipient_list=[token.email],
html_message=html_message,
fail_silently=False,
)
```
---
### 3. Views
#### Add Public Consent Signing Views (`core/views.py`)
```python
class ConsentSignPublicView(TemplateView):
"""
Public view for signing consent via email link.
No authentication required.
"""
template_name = 'core/consent_sign_public.html'
def get_context_data(self, **kwargs):
"""Add token and consent to context."""
context = super().get_context_data(**kwargs)
token_string = self.kwargs.get('token')
# Verify token
is_valid, message, token = ConsentEmailService.verify_token(token_string)
context['is_valid'] = is_valid
context['message'] = message
context['token'] = token
if token:
context['consent'] = token.consent
context['patient'] = token.consent.patient
return context
class ConsentSignPublicSubmitView(View):
"""
Handle consent signing submission from public form.
No authentication required.
"""
def post(self, request, token):
"""Process consent signing."""
# Verify token
is_valid, message, token_obj = ConsentEmailService.verify_token(token)
if not is_valid:
messages.error(request, message)
return redirect('core:consent_sign_public', token=token)
# Get form data
signed_by_name = request.POST.get('signed_by_name')
signed_by_relationship = request.POST.get('signed_by_relationship')
signature_method = request.POST.get('signature_method', 'TYPED')
signature_data = request.POST.get('signature_data') # Base64 if drawn
# Validate required fields
if not signed_by_name or not signed_by_relationship:
messages.error(request, "Please provide your name and relationship.")
return redirect('core:consent_sign_public', token=token)
# Handle signature image if drawn
signature_image = None
if signature_method == 'DRAWN' and signature_data:
# Convert base64 to image file
import base64
from django.core.files.base import ContentFile
format, imgstr = signature_data.split(';base64,')
ext = format.split('/')[-1]
signature_image = ContentFile(
base64.b64decode(imgstr),
name=f'signature_{token_obj.consent.id}.{ext}'
)
# Get IP and user agent
signed_ip = self.get_client_ip(request)
signed_user_agent = request.META.get('HTTP_USER_AGENT', '')[:255]
try:
# Sign consent
consent = ConsentEmailService.sign_consent_via_token(
token=token_obj,
signed_by_name=signed_by_name,
signed_by_relationship=signed_by_relationship,
signature_method=signature_method,
signature_image=signature_image,
signed_ip=signed_ip,
signed_user_agent=signed_user_agent
)
# Success
return render(request, 'core/consent_sign_success.html', {
'consent': consent,
'patient': consent.patient,
})
except Exception as e:
logger.error(f"Error signing consent via token: {e}")
messages.error(request, "An error occurred while signing the consent. Please try again.")
return redirect('core:consent_sign_public', token=token)
def get_client_ip(self, request):
"""Get client IP address."""
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
if x_forwarded_for:
ip = x_forwarded_for.split(',')[0]
else:
ip = request.META.get('REMOTE_ADDR')
return ip
class ConsentSendEmailView(LoginRequiredMixin, RolePermissionMixin, View):
"""
Staff view to send consent form via email.
Requires authentication.
"""
allowed_roles = [User.Role.ADMIN, User.Role.DOCTOR, User.Role.NURSE,
User.Role.FRONT_DESK]
def post(self, request, consent_id):
"""Send consent form via email."""
try:
consent = Consent.objects.get(
id=consent_id,
tenant=request.user.tenant
)
# Get email from form
email = request.POST.get('email')
if not email:
messages.error(request, "Please provide an email address.")
return redirect('core:consent_detail', pk=consent_id)
# Send consent
token = ConsentEmailService.send_consent_for_signing(
consent=consent,
email=email,
sent_by=request.user,
expiry_hours=72 # 3 days
)
messages.success(
request,
f"Consent form sent to {email}. Link expires in 72 hours."
)
return redirect('core:consent_detail', pk=consent_id)
except Consent.DoesNotExist:
messages.error(request, "Consent not found.")
return redirect('core:consent_list')
except Exception as e:
logger.error(f"Error sending consent email: {e}")
messages.error(request, "Failed to send email. Please try again.")
return redirect('core:consent_detail', pk=consent_id)
```
---
### 4. URLs
#### Add URL Patterns (`core/urls.py`)
```python
from django.urls import path
from .views import (
# ... existing views ...
ConsentSignPublicView,
ConsentSignPublicSubmitView,
ConsentSendEmailView,
)
app_name = 'core'
urlpatterns = [
# ... existing patterns ...
# Public consent signing (no auth required)
path(
'consent/sign/<str:token>/',
ConsentSignPublicView.as_view(),
name='consent_sign_public'
),
path(
'consent/sign/<str:token>/submit/',
ConsentSignPublicSubmitView.as_view(),
name='consent_sign_public_submit'
),
# Staff: Send consent via email
path(
'consent/<uuid:consent_id>/send-email/',
ConsentSendEmailView.as_view(),
name='consent_send_email'
),
]
```
---
### 5. Email Templates
#### Request Email (`templates/emails/consent_signing_request.html`)
```html
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Consent Form - {{ clinic_name }}</title>
</head>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
<h2 style="color: #2c3e50;">Consent Form for {{ patient.full_name_en }}</h2>
<p>Dear Parent/Guardian,</p>
<p>{{ clinic_name }} requires your consent for treatment services for <strong>{{ patient.full_name_en }}</strong>.</p>
<p>Please review and sign the consent form by clicking the button below:</p>
<div style="text-align: center; margin: 30px 0;">
<a href="{{ signing_url }}"
style="background-color: #3498db; color: white; padding: 12px 30px;
text-decoration: none; border-radius: 5px; display: inline-block;">
Review and Sign Consent
</a>
</div>
<p><strong>Important:</strong></p>
<ul>
<li>This link will expire on {{ expires_at|date:"F d, Y at g:i A" }}</li>
<li>You do not need to create an account or log in</li>
<li>The consent form can only be signed once</li>
</ul>
<p>If you have any questions, please contact us at {{ clinic_name }}.</p>
<hr style="border: none; border-top: 1px solid #eee; margin: 30px 0;">
<p style="font-size: 12px; color: #7f8c8d;">
This is an automated message from {{ clinic_name }}.
If you received this email in error, please disregard it.
</p>
</div>
</body>
</html>
```
#### Request Email Text Version (`templates/emails/consent_signing_request.txt`)
```
Consent Form for {{ patient.full_name_en }}
Dear Parent/Guardian,
{{ clinic_name }} requires your consent for treatment services for {{ patient.full_name_en }}.
Please review and sign the consent form by visiting this link:
{{ signing_url }}
IMPORTANT:
- This link will expire on {{ expires_at|date:"F d, Y at g:i A" }}
- You do not need to create an account or log in
- The consent form can only be signed once
If you have any questions, please contact us at {{ clinic_name }}.
---
This is an automated message from {{ clinic_name }}.
If you received this email in error, please disregard it.
```
#### Confirmation Email (`templates/emails/consent_signed_confirmation.html`)
```html
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Consent Signed - {{ clinic_name }}</title>
</head>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
<h2 style="color: #27ae60;">✓ Consent Form Signed Successfully</h2>
<p>Dear {{ signed_by_name }},</p>
<p>Thank you for signing the consent form for <strong>{{ patient.full_name_en }}</strong>.</p>
<div style="background-color: #f8f9fa; padding: 15px; border-left: 4px solid #27ae60; margin: 20px 0;">
<p style="margin: 0;"><strong>Consent Details:</strong></p>
<ul style="margin: 10px 0;">
<li>Patient: {{ patient.full_name_en }}</li>
<li>Signed by: {{ signed_by_name }}</li>
<li>Signed on: {{ signed_at|date:"F d, Y at g:i A" }}</li>
<li>Consent Type: {{ consent.get_consent_type_display }}</li>
</ul>
</div>
<p>A copy of this consent has been recorded in our system.</p>
<p>If you have any questions, please contact {{ clinic_name }}.</p>
<hr style="border: none; border-top: 1px solid #eee; margin: 30px 0;">
<p style="font-size: 12px; color: #7f8c8d;">
This is an automated confirmation from {{ clinic_name }}.
</p>
</div>
</body>
</html>
```
---
### 6. Public Consent Signing Template
#### Main Template (`templates/core/consent_sign_public.html`)
```html
{% extends "base_public.html" %}
{% load static %}
{% block title %}Sign Consent Form{% endblock %}
{% block content %}
<div class="container mt-5">
<div class="row justify-content-center">
<div class="col-md-8">
{% if not is_valid %}
<div class="alert alert-danger">
<h4>Invalid or Expired Link</h4>
<p>{{ message }}</p>
<p>Please contact the clinic for a new consent link.</p>
</div>
{% else %}
<div class="card">
<div class="card-header bg-primary text-white">
<h3>Consent Form</h3>
</div>
<div class="card-body">
<h4>Patient: {{ patient.full_name_en }}</h4>
<p class="text-muted">MRN: {{ patient.mrn }}</p>
<hr>
<h5>{{ consent.get_consent_type_display }}</h5>
<div class="consent-content" style="max-height: 400px; overflow-y: auto;
border: 1px solid #ddd; padding: 15px;
margin: 20px 0; background-color: #f8f9fa;">
{{ consent.content_text|linebreaks }}
</div>
<form method="post" action="{% url 'core:consent_sign_public_submit' token=token.token %}"
id="consentForm">
{% csrf_token %}
<div class="form-group">
<label for="signed_by_name">Your Full Name *</label>
<input type="text" class="form-control" id="signed_by_name"
name="signed_by_name" required>
</div>
<div class="form-group">
<label for="signed_by_relationship">Relationship to Patient *</label>
<select class="form-control" id="signed_by_relationship"
name="signed_by_relationship" required>
<option value="">Select...</option>
<option value="Mother">Mother</option>
<option value="Father">Father</option>
<option value="Guardian">Legal Guardian</option>
<option value="Other">Other</option>
</select>
</div>
<div class="form-group">
<label>Signature Method *</label>
<div class="btn-group btn-group-toggle d-block" data-toggle="buttons">
<label class="btn btn-outline-primary active">
<input type="radio" name="signature_method" value="TYPED" checked>
Type Name
</label>
<label class="btn btn-outline-primary">
<input type="radio" name="signature_method" value="DRAWN">
Draw Signature
</label>
</div>
</div>
<div id="signaturePad" style="display: none;">
<label>Draw Your Signature</label>
<canvas id="signatureCanvas"
style="border: 1px solid #000; width: 100%; height: 200px;">
</canvas>
<button type="button" class="btn btn-sm btn-secondary mt-2"
onclick="clearSignature()">
Clear
</button>
<input type="hidden" name="signature_data" id="signatureData">
</div>
<div class="form-check mt-3">
<input type="checkbox" class="form-check-input" id="agreeCheck" required>
<label class="form-check-label" for="agreeCheck">
I have read and agree to the above consent form *
</label>
</div>
<button type="submit" class="btn btn-primary btn-lg btn-block mt-4">
Sign Consent Form
</button>
</form>
</div>
</div>
<p class="text-center text-muted mt-3">
<small>This link expires on {{ token.expires_at|date:"F d, Y at g:i A" }}</small>
</p>
{% endif %}
</div>
</div>
</div>
<script>
// Signature pad functionality
let canvas, ctx, isDrawing = false;
document.querySelectorAll('input[name="signature_method"]').forEach(radio => {
radio.addEventListener('change', function() {
const signaturePad = document.getElementById('signaturePad');
if (this.value === 'DRAWN') {
signaturePad.style.display = 'block';
initSignaturePad();
} else {
signaturePad.style.display = 'none';
}
});
});
function initSignaturePad() {
canvas = document.getElementById('signatureCanvas');
ctx = canvas.getContext('2d');
// Set canvas size
canvas.width = canvas.offsetWidth;
canvas.height = 200;
// Mouse events
canvas.addEventListener('mousedown', startDrawing);
canvas.addEventListener('mousemove', draw);
canvas.addEventListener('mouseup', stopDrawing);
canvas.addEventListener('mouseout', stopDrawing);
// Touch events
canvas.addEventListener('touchstart', handleTouch);
canvas.addEventListener('touchmove', handleTouch);
canvas.addEventListener('touchend', stopDrawing);
}
function startDrawing(e) {
isDrawing = true;
ctx.beginPath();
ctx.moveTo(e.offsetX, e.offsetY);
}
function draw(e) {
if (!isDrawing) return;
ctx.lineTo(e.offsetX, e.offsetY);
ctx.stroke();
}
function stopDrawing() {
if (isDrawing) {
isDrawing = false;
// Save signature as base64
document.getElementById('signatureData').value = canvas.toDataURL();
}
}
function handleTouch(e) {
e.preventDefault();
const touch = e.touches[0];
const rect = canvas.getBoundingClientRect();
const x = touch.clientX - rect.left;
const y = touch.clientY - rect.top;
if (e.type === 'touchstart') {
isDrawing = true;
ctx.beginPath();
ctx.moveTo(x, y);
} else if (e.type === 'touchmove' && isDrawing) {
ctx.lineTo(x, y);
ctx.stroke();
}
}
function clearSignature() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
document.getElementById('signatureData').value = '';
}
</script>
{% endblock %}
```
---
### 7. Settings Configuration
#### Add to `AgdarCentre/settings.py`
```python
# Site URL for email links
SITE_URL = env('SITE_URL', default='http://localhost:8000')
# Email configuration
EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
EMAIL_HOST = env('EMAIL_HOST', default='smtp.gmail.com')
EMAIL_PORT = env.int('EMAIL_PORT', default=587)
EMAIL_USE_TLS = env.bool('EMAIL_USE_TLS', default=True)
EMAIL_HOST_USER = env('EMAIL_HOST_USER', default='')
EMAIL_HOST_PASSWORD = env('EMAIL_HOST_PASSWORD', default='')
DEFAULT_FROM_EMAIL = env('DEFAULT_FROM_EMAIL', default='noreply@agdarcentre.com')
```
#### Add to `.env`
```
SITE_URL=https://yourdomain.com
EMAIL_HOST=smtp.gmail.com
EMAIL_PORT=587
EMAIL_USE_TLS=True
EMAIL_HOST_USER=your-email@gmail.com
EMAIL_HOST_PASSWORD=your-app-password
DEFAULT_FROM_EMAIL=noreply@agdarcentre.com
```
---
### 8. Admin Interface
#### Add to `core/admin.py`
```python
@admin.register(ConsentToken)
class ConsentTokenAdmin(admin.ModelAdmin):
"""Admin interface for Consent Tokens."""
list_display = ['consent', 'email', 'created_at', 'expires_at', 'used_at', 'is_active']
list_filter = ['is_active', 'created_at', 'expires_at']
search_fields = ['email', 'consent__patient__first_name_en', 'consent__patient__mrn']
readonly_fields = ['token', 'created_at', 'used_at']
date_hierarchy = 'created_at'
```
---
### 9. Staff UI Integration
#### Add "Send via Email" Button to Consent Detail Page
```html
<!-- In consent detail template -->
<div class="card-footer">
<button type="button" class="btn btn-primary" data-toggle="modal" data-target="#sendEmailModal">
<i class="fas fa-envelope"></i> Send via Email
</button>
</div>
<!-- Email Modal -->
<div class="modal fade" id="sendEmailModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<form method="