# 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//', ConsentSignPublicView.as_view(), name='consent_sign_public' ), path( 'consent/sign//submit/', ConsentSignPublicSubmitView.as_view(), name='consent_sign_public_submit' ), # Staff: Send consent via email path( 'consent//send-email/', ConsentSendEmailView.as_view(), name='consent_send_email' ), ] ``` --- ### 5. Email Templates #### Request Email (`templates/emails/consent_signing_request.html`) ```html Consent Form - {{ clinic_name }}

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 clicking the button below:

Review and Sign Consent

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.

``` #### 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 Consent Signed - {{ clinic_name }}

✓ Consent Form Signed Successfully

Dear {{ signed_by_name }},

Thank you for signing the consent form for {{ patient.full_name_en }}.

Consent Details:

  • Patient: {{ patient.full_name_en }}
  • Signed by: {{ signed_by_name }}
  • Signed on: {{ signed_at|date:"F d, Y at g:i A" }}
  • Consent Type: {{ consent.get_consent_type_display }}

A copy of this consent has been recorded in our system.

If you have any questions, please contact {{ clinic_name }}.


This is an automated confirmation from {{ clinic_name }}.

``` --- ### 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 %}
{% if not is_valid %}

Invalid or Expired Link

{{ message }}

Please contact the clinic for a new consent link.

{% else %}

Consent Form

Patient: {{ patient.full_name_en }}

MRN: {{ patient.mrn }}


{{ consent.get_consent_type_display }}
{% csrf_token %}

This link expires on {{ token.expires_at|date:"F d, Y at g:i A" }}

{% endif %}
{% 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