925 lines
30 KiB
Markdown
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="
|