""" Common mixins for views across the Tenhal Multidisciplinary Healthcare Platform. These mixins provide reusable functionality for tenant filtering, permissions, audit logging, and HTMX support. """ from django.contrib import messages from django.contrib.auth.mixins import UserPassesTestMixin from django.core.exceptions import ImproperlyConfigured, PermissionDenied from django.shortcuts import redirect, render from django.urls import reverse from django.utils import timezone from .models import AuditLog class TenantFilterMixin: """ Mixin to automatically filter querysets by the current user's tenant. Usage: class MyListView(TenantFilterMixin, ListView): model = MyModel """ def get_queryset(self): """Filter queryset by tenant.""" queryset = super().get_queryset() # Check if model has tenant field if hasattr(queryset.model, 'tenant'): return queryset.filter(tenant=self.request.user.tenant) return queryset class RolePermissionMixin(UserPassesTestMixin): """ Mixin to check if user has required role(s) to access the view. Usage: class MyView(RolePermissionMixin, DetailView): allowed_roles = ['ADMIN', 'DOCTOR'] """ allowed_roles = [] # Override in subclass def test_func(self): """Check if user's role is in allowed_roles.""" if not self.allowed_roles: # If no roles specified, allow all authenticated users return self.request.user.is_authenticated return self.request.user.role in self.allowed_roles def handle_no_permission(self): """Custom handling for permission denied.""" raise PermissionDenied( f"You need one of these roles to access this page: {', '.join(self.allowed_roles)}" ) class AuditLogMixin: """ Mixin to automatically log create, update, and delete actions. Usage: class MyCreateView(AuditLogMixin, CreateView): model = MyModel """ def form_valid(self, form): """Log the action after successful form submission.""" response = super().form_valid(form) # Determine action type if hasattr(self, 'object') and self.object: if hasattr(self.object, 'pk') and self.object.pk: # Check if this is a new object or update action = 'UPDATE' if form.instance.pk else 'CREATE' else: action = 'CREATE' else: action = 'UNKNOWN' # Create audit log entry try: AuditLog.objects.create( tenant=self.request.user.tenant, user=self.request.user, action=action, model_name=self.model.__name__, object_id=str(self.object.pk) if hasattr(self, 'object') else None, changes=self._get_changed_fields(form), ip_address=self._get_client_ip(), user_agent=self.request.META.get('HTTP_USER_AGENT', '')[:255], ) except Exception as e: # Log error but don't fail the request print(f"Failed to create audit log: {e}") return response def _get_changed_fields(self, form): """Get dictionary of changed fields.""" if not hasattr(form, 'changed_data'): return {} changes = {} for field in form.changed_data: if field in form.cleaned_data: changes[field] = str(form.cleaned_data[field]) return changes def _get_client_ip(self): """Get client IP address from request.""" x_forwarded_for = self.request.META.get('HTTP_X_FORWARDED_FOR') if x_forwarded_for: ip = x_forwarded_for.split(',')[0] else: ip = self.request.META.get('REMOTE_ADDR') return ip def delete(self, request, *args, **kwargs): """Log delete action.""" # Get object before deletion self.object = self.get_object() object_id = str(self.object.pk) model_name = self.object.__class__.__name__ # Perform deletion response = super().delete(request, *args, **kwargs) # Create audit log entry try: AuditLog.objects.create( tenant=request.user.tenant, user=request.user, action='DELETE', model_name=model_name, object_id=object_id, ip_address=self._get_client_ip(), user_agent=request.META.get('HTTP_USER_AGENT', '')[:255], ) except Exception as e: print(f"Failed to create audit log: {e}") return response class HTMXResponseMixin: """ Mixin to handle HTMX requests with partial template rendering. Usage: class MyView(HTMXResponseMixin, ListView): template_name = 'myapp/list.html' htmx_template_name = 'myapp/partials/list_partial.html' """ htmx_template_name = None # Override in subclass def render_to_response(self, context, **response_kwargs): """Render partial template for HTMX requests.""" # Check if this is an HTMX request if self.request.headers.get('HX-Request'): # Use partial template if specified if self.htmx_template_name: return render( self.request, self.htmx_template_name, context, **response_kwargs ) # Default rendering for non-HTMX requests return super().render_to_response(context, **response_kwargs) class ObjectOwnershipMixin: """ Mixin to check if user has permission to access/modify an object. Checks: 1. Object belongs to user's tenant 2. User has appropriate role 3. Optional: User is the creator/owner Usage: class MyUpdateView(ObjectOwnershipMixin, UpdateView): check_ownership = True # Require user to be owner """ check_ownership = False # Set to True to require ownership ownership_field = 'created_by' # Field to check for ownership def get_object(self, queryset=None): """Get object and verify permissions.""" obj = super().get_object(queryset) # Check tenant if hasattr(obj, 'tenant') and obj.tenant != self.request.user.tenant: raise PermissionDenied("You don't have permission to access this object.") # Check ownership if required if self.check_ownership: if hasattr(obj, self.ownership_field): owner = getattr(obj, self.ownership_field) if owner != self.request.user: raise PermissionDenied("You don't have permission to modify this object.") return obj class SuccessMessageMixin: """ Mixin to add success messages after form submission. Usage: class MyCreateView(SuccessMessageMixin, CreateView): success_message = "Object created successfully!" """ success_message = "" def form_valid(self, form): """Add success message.""" response = super().form_valid(form) if self.success_message: from django.contrib import messages messages.success(self.request, self.success_message) return response class PaginationMixin: """ Mixin to add consistent pagination across list views. Usage: class MyListView(PaginationMixin, ListView): paginate_by = 25 """ paginate_by = 25 # Default pagination def get_context_data(self, **kwargs): """Add pagination context.""" context = super().get_context_data(**kwargs) # Add pagination info if 'page_obj' in context: page_obj = context['page_obj'] context['pagination_info'] = { 'current_page': page_obj.number, 'total_pages': page_obj.paginator.num_pages, 'total_items': page_obj.paginator.count, 'has_previous': page_obj.has_previous(), 'has_next': page_obj.has_next(), 'start_index': page_obj.start_index(), 'end_index': page_obj.end_index(), } return context class SignedDocumentEditPreventionMixin: """ Mixin to prevent editing of signed clinical documents. This mixin checks if a document has been signed and prevents access to update views for signed documents. It should be used on all clinical UpdateView classes that use ClinicallySignableMixin. Features: - Checks if document is signed in dispatch() method - Prevents access to update views for signed documents - Shows appropriate error message - Redirects to detail view - Allows admins to override (optional) Attributes: allow_admin_edit_signed (bool): Optional. Set to True to allow admins to edit signed documents. Defaults to False. signed_error_message (str): Optional. Custom error message to display. Usage: class ABASessionUpdateView(SignedDocumentEditPreventionMixin, UpdateView): model = ABASession # allow_admin_edit_signed = True # Uncomment to allow admin edits """ allow_admin_edit_signed = False signed_error_message = None def dispatch(self, request, *args, **kwargs): """Check if document is signed before allowing edit access.""" # Get the object self.object = self.get_object() # Check if object has signed_by field (uses ClinicallySignableMixin) if hasattr(self.object, 'signed_by') and self.object.signed_by: # Check if admin override is allowed if self.allow_admin_edit_signed and request.user.role == 'ADMIN': # Allow admin to edit, but show warning messages.warning( request, "Warning: You are editing a signed document as an administrator. " "This action will be logged in the audit trail." ) else: # Prevent editing error_msg = self.signed_error_message or ( "This document has been signed and can no longer be edited. " f"Signed by {self.object.signed_by.get_full_name()} " f"on {self.object.signed_at.strftime('%Y-%m-%d %H:%M')}." ) messages.error(request, error_msg) # Redirect to detail view detail_url = self.get_signed_redirect_url() return redirect(detail_url) # Document not signed, proceed with normal dispatch return super().dispatch(request, *args, **kwargs) def get_signed_redirect_url(self): """ Get URL to redirect to when document is signed. By default, tries to construct detail URL from model name. Override this method if you need custom redirect logic. Returns: str: URL to redirect to """ # Try to construct detail URL from model name model_name = self.model.__name__.lower() app_label = self.model._meta.app_label try: # Try common URL patterns url_name = f'{app_label}:{model_name}_detail' return reverse(url_name, kwargs={'pk': self.object.pk}) except: # Fallback: try without app label try: url_name = f'{model_name}_detail' return reverse(url_name, kwargs={'pk': self.object.pk}) except: # Last resort: redirect to list view try: url_name = f'{app_label}:{model_name}_list' return reverse(url_name) except: # Give up and redirect to home return reverse('core:dashboard') class ConsentRequiredMixin: """ Mixin to enforce consent verification before creating clinical documentation. This mixin checks if a patient has signed the required consent forms before allowing clinical documentation to be created. It should be used on all clinical CreateView classes (consultations, sessions, assessments, etc.). Attributes: consent_service_type (str): Required. The service type to check consent for (e.g., 'ABA', 'MEDICAL', 'OT', 'SLP'). consent_redirect_url (str): Optional. URL to redirect to if consent missing. Defaults to consent creation page. consent_error_message (str): Optional. Custom error message to display. consent_skip_check (bool): Optional. Set to True to skip consent check (useful for testing or special cases). Methods: get_patient(): Must be implemented by subclass to return the Patient instance. Usage: class ABAConsultCreateView(ConsentRequiredMixin, CreateView): consent_service_type = 'ABA' def get_patient(self): patient_id = self.request.GET.get('patient') return Patient.objects.get(pk=patient_id, tenant=self.request.user.tenant) Example with custom error message: class MedicalConsultCreateView(ConsentRequiredMixin, CreateView): consent_service_type = 'MEDICAL' consent_error_message = "Patient must sign medical consent before consultation." def get_patient(self): return self.get_object().patient """ consent_service_type = None consent_redirect_url = None consent_error_message = None consent_skip_check = False def dispatch(self, request, *args, **kwargs): """Check consent before allowing access to the view.""" # Skip check if explicitly disabled if self.consent_skip_check: return super().dispatch(request, *args, **kwargs) # Validate configuration if not self.consent_service_type: raise ImproperlyConfigured( f"{self.__class__.__name__} must define consent_service_type attribute. " f"Set it to the service type (e.g., 'ABA', 'MEDICAL', 'OT', 'SLP')." ) # Get patient try: patient = self.get_patient() except Exception as e: messages.error( request, f"Error retrieving patient information: {str(e)}. " f"Please ensure patient is specified correctly." ) return redirect('core:patient_list') # Only check consent if patient is available if patient: # Import here to avoid circular imports from core.services import ConsentService # Verify consent has_consent, consent_message = ConsentService.verify_consent_for_service( patient, self.consent_service_type ) if not has_consent: # Get missing consents for detailed feedback missing_consents = ConsentService.get_missing_consents( patient, self.consent_service_type ) # Build error message if self.consent_error_message: error_msg = self.consent_error_message else: error_msg = ( f"Cannot create {self.consent_service_type} documentation: {consent_message}. " ) if missing_consents: error_msg += f"Missing consent types: {', '.join(missing_consents)}." messages.error(request, error_msg) # Determine redirect URL if self.consent_redirect_url: redirect_url = self.consent_redirect_url else: # Default: redirect to patient detail page with consent tab redirect_url = reverse('core:patient_detail', kwargs={'pk': patient.pk}) redirect_url += '?tab=consents&missing=' + ','.join(missing_consents) return redirect(redirect_url) # All checks passed, proceed with normal dispatch return super().dispatch(request, *args, **kwargs) def get_patient(self): """ Get the patient for consent verification. This method must be implemented by the subclass to return the Patient instance that needs consent verification. Common implementations: - Get from URL parameter: Patient.objects.get(pk=self.request.GET.get('patient')) - Get from form: self.get_form().instance.patient - Get from appointment: Appointment.objects.get(pk=...).patient Returns: Patient: The patient instance to check consent for Raises: NotImplementedError: If not implemented by subclass """ raise NotImplementedError( f"{self.__class__.__name__} must implement get_patient() method. " f"This method should return the Patient instance for consent verification." )