1277 lines
36 KiB
Python
1277 lines
36 KiB
Python
"""
|
|
Core models for the Tenhal Multidisciplinary Healthcare Platform.
|
|
|
|
This module contains the base models and mixins used throughout the application,
|
|
as well as core entities like User, Patient, Tenant, File, Consent, etc.
|
|
"""
|
|
|
|
import uuid
|
|
from django.contrib.auth.models import AbstractUser
|
|
from django.contrib.contenttypes.fields import GenericForeignKey
|
|
from django.contrib.contenttypes.models import ContentType
|
|
from django.db import models
|
|
from django.utils.translation import gettext_lazy as _
|
|
from phonenumber_field.modelfields import PhoneNumberField
|
|
from simple_history.models import HistoricalRecords
|
|
|
|
|
|
# ============================================================================
|
|
# BASE MIXINS
|
|
# ============================================================================
|
|
|
|
class UUIDPrimaryKeyMixin(models.Model):
|
|
"""Mixin to use UUID as primary key for all models."""
|
|
|
|
id = models.UUIDField(
|
|
primary_key=True,
|
|
default=uuid.uuid4,
|
|
editable=False,
|
|
verbose_name=_("ID")
|
|
)
|
|
|
|
class Meta:
|
|
abstract = True
|
|
|
|
|
|
class TimeStampedMixin(models.Model):
|
|
"""Mixin to add created_at and updated_at timestamps."""
|
|
|
|
created_at = models.DateTimeField(
|
|
auto_now_add=True,
|
|
verbose_name=_("Created At")
|
|
)
|
|
updated_at = models.DateTimeField(
|
|
auto_now=True,
|
|
verbose_name=_("Updated At")
|
|
)
|
|
|
|
class Meta:
|
|
abstract = True
|
|
|
|
|
|
class TenantOwnedMixin(models.Model):
|
|
"""Mixin for models that belong to a tenant."""
|
|
|
|
tenant = models.ForeignKey(
|
|
'core.Tenant',
|
|
on_delete=models.CASCADE,
|
|
related_name='%(app_label)s_%(class)s_related',
|
|
verbose_name=_("Tenant")
|
|
)
|
|
|
|
class Meta:
|
|
abstract = True
|
|
|
|
|
|
class ClinicallySignableMixin(models.Model):
|
|
"""Mixin for clinical documents that can be signed."""
|
|
|
|
signed_by = models.ForeignKey(
|
|
'core.User',
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name='%(app_label)s_%(class)s_signed',
|
|
verbose_name=_("Signed By")
|
|
)
|
|
signed_at = models.DateTimeField(
|
|
null=True,
|
|
blank=True,
|
|
verbose_name=_("Signed At")
|
|
)
|
|
|
|
class Meta:
|
|
abstract = True
|
|
|
|
|
|
class SoftDeleteMixin(models.Model):
|
|
"""Mixin for soft delete functionality."""
|
|
|
|
is_deleted = models.BooleanField(
|
|
default=False,
|
|
verbose_name=_("Is Deleted")
|
|
)
|
|
deleted_at = models.DateTimeField(
|
|
null=True,
|
|
blank=True,
|
|
verbose_name=_("Deleted At")
|
|
)
|
|
deleted_by = models.ForeignKey(
|
|
'User',
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name='deleted_%(class)s_set',
|
|
verbose_name=_("Deleted By")
|
|
)
|
|
|
|
class Meta:
|
|
abstract = True
|
|
|
|
|
|
# ============================================================================
|
|
# CORE MODELS
|
|
# ============================================================================
|
|
|
|
class Tenant(UUIDPrimaryKeyMixin, TimeStampedMixin):
|
|
"""
|
|
Tenant model for multi-tenancy support.
|
|
Each clinic/organization is a tenant.
|
|
"""
|
|
|
|
name = models.CharField(
|
|
max_length=200,
|
|
verbose_name=_("Name")
|
|
)
|
|
name_ar = models.CharField(
|
|
max_length=200,
|
|
blank=True,
|
|
verbose_name=_("Name (Arabic)")
|
|
)
|
|
code = models.CharField(
|
|
max_length=50,
|
|
unique=True,
|
|
verbose_name=_("Code")
|
|
)
|
|
|
|
# ZATCA E-Invoice Required Fields
|
|
vat_number = models.CharField(
|
|
max_length=15,
|
|
unique=True,
|
|
blank=True,
|
|
verbose_name=_("VAT Registration Number"),
|
|
help_text=_("15 digits, must start and end with 3 (e.g., 300000000000003)")
|
|
)
|
|
|
|
# Address Information (for ZATCA invoices)
|
|
address = models.TextField(
|
|
blank=True,
|
|
verbose_name=_("Address")
|
|
)
|
|
city = models.CharField(
|
|
max_length=100,
|
|
default='Riyadh',
|
|
verbose_name=_("City")
|
|
)
|
|
postal_code = models.CharField(
|
|
max_length=20,
|
|
default='12345',
|
|
verbose_name=_("Postal Code")
|
|
)
|
|
country_code = models.CharField(
|
|
max_length=2,
|
|
default='SA',
|
|
verbose_name=_("Country Code")
|
|
)
|
|
|
|
is_active = models.BooleanField(
|
|
default=True,
|
|
verbose_name=_("Is Active")
|
|
)
|
|
settings = models.JSONField(
|
|
default=dict,
|
|
blank=True,
|
|
verbose_name=_("Settings")
|
|
)
|
|
|
|
class Meta:
|
|
verbose_name = _("Tenant")
|
|
verbose_name_plural = _("Tenants")
|
|
ordering = ['name']
|
|
|
|
def __str__(self):
|
|
return self.name
|
|
|
|
|
|
class User(AbstractUser, UUIDPrimaryKeyMixin):
|
|
"""
|
|
Custom User model extending Django's AbstractUser.
|
|
Supports role-based access control and user profiles.
|
|
"""
|
|
|
|
class Role(models.TextChoices):
|
|
ADMIN = 'ADMIN', _('Administrator')
|
|
DOCTOR = 'DOCTOR', _('Doctor')
|
|
NURSE = 'NURSE', _('Nurse')
|
|
OT = 'OT', _('Occupational Therapist')
|
|
SLP = 'SLP', _('Speech-Language Pathologist')
|
|
ABA = 'ABA', _('ABA Therapist')
|
|
FRONT_DESK = 'FRONT_DESK', _('Front Desk')
|
|
FINANCE = 'FINANCE', _('Finance')
|
|
|
|
tenant = models.ForeignKey(
|
|
Tenant,
|
|
on_delete=models.CASCADE,
|
|
related_name='users',
|
|
null=True,
|
|
blank=True,
|
|
verbose_name=_("Tenant")
|
|
)
|
|
role = models.CharField(
|
|
max_length=20,
|
|
choices=Role.choices,
|
|
default=Role.FRONT_DESK,
|
|
verbose_name=_("Role")
|
|
)
|
|
phone_number = PhoneNumberField(
|
|
blank=True,
|
|
verbose_name=_("Phone Number")
|
|
)
|
|
employee_id = models.CharField(
|
|
max_length=50,
|
|
blank=True,
|
|
verbose_name=_("Employee ID")
|
|
)
|
|
|
|
# Profile fields
|
|
profile_picture = models.ImageField(
|
|
upload_to='profile_pictures/',
|
|
null=True,
|
|
blank=True,
|
|
verbose_name=_("Profile Picture")
|
|
)
|
|
bio = models.TextField(
|
|
blank=True,
|
|
verbose_name=_("Bio"),
|
|
help_text=_("Brief description about yourself")
|
|
)
|
|
preferences = models.JSONField(
|
|
default=dict,
|
|
blank=True,
|
|
verbose_name=_("Preferences"),
|
|
help_text=_("User preferences (language, notifications, etc.)")
|
|
)
|
|
email_verified = models.BooleanField(
|
|
default=False,
|
|
verbose_name=_("Email Verified")
|
|
)
|
|
last_login_ip = models.GenericIPAddressField(
|
|
null=True,
|
|
blank=True,
|
|
verbose_name=_("Last Login IP")
|
|
)
|
|
timezone = models.CharField(
|
|
max_length=50,
|
|
default='Asia/Riyadh',
|
|
verbose_name=_("Timezone")
|
|
)
|
|
|
|
history = HistoricalRecords()
|
|
|
|
class Meta:
|
|
verbose_name = _("User")
|
|
verbose_name_plural = _("Users")
|
|
ordering = ['last_name', 'first_name']
|
|
|
|
def __str__(self):
|
|
return f"{self.get_full_name()} ({self.get_role_display()})"
|
|
|
|
def get_profile_completion(self):
|
|
"""Calculate profile completion percentage."""
|
|
fields = [
|
|
self.first_name,
|
|
self.last_name,
|
|
self.email,
|
|
self.phone_number,
|
|
self.employee_id,
|
|
self.profile_picture,
|
|
self.bio,
|
|
]
|
|
completed = sum(1 for field in fields if field)
|
|
return int((completed / len(fields)) * 100)
|
|
|
|
def get_initials(self):
|
|
"""Get user initials for avatar."""
|
|
if self.first_name and self.last_name:
|
|
return f"{self.first_name[0]}{self.last_name[0]}".upper()
|
|
elif self.first_name:
|
|
return self.first_name[0].upper()
|
|
elif self.username:
|
|
return self.username[0].upper()
|
|
return "U"
|
|
|
|
|
|
class Patient(UUIDPrimaryKeyMixin, TimeStampedMixin, TenantOwnedMixin):
|
|
"""
|
|
Patient model storing demographic and contact information.
|
|
"""
|
|
|
|
class Sex(models.TextChoices):
|
|
MALE = 'M', _('Male')
|
|
FEMALE = 'F', _('Female')
|
|
|
|
# Medical Record Number (auto-generated)
|
|
mrn = models.CharField(
|
|
max_length=20,
|
|
unique=True,
|
|
editable=False,
|
|
verbose_name=_("MRN")
|
|
)
|
|
|
|
# Identification
|
|
national_id = models.CharField(
|
|
max_length=20,
|
|
blank=True,
|
|
verbose_name=_("National ID")
|
|
)
|
|
|
|
# Names (bilingual)
|
|
first_name_en = models.CharField(
|
|
max_length=100,
|
|
verbose_name=_("First Name (English)")
|
|
)
|
|
father_name_en = models.CharField(
|
|
max_length=100,
|
|
blank=True,
|
|
verbose_name=_("Father Name (English)")
|
|
)
|
|
grandfather_name_en = models.CharField(
|
|
max_length=100,
|
|
blank=True,
|
|
verbose_name=_("Grandfather Name (English)")
|
|
)
|
|
last_name_en = models.CharField(
|
|
max_length=100,
|
|
verbose_name=_("Last Name (English)")
|
|
)
|
|
first_name_ar = models.CharField(
|
|
max_length=100,
|
|
blank=True,
|
|
verbose_name=_("First Name (Arabic)")
|
|
)
|
|
father_name_ar = models.CharField(
|
|
max_length=100,
|
|
blank=True,
|
|
verbose_name=_("Father Name (Arabic)")
|
|
)
|
|
grandfather_name_ar = models.CharField(
|
|
max_length=100,
|
|
blank=True,
|
|
verbose_name=_("Grandfather Name (Arabic)")
|
|
)
|
|
last_name_ar = models.CharField(
|
|
max_length=100,
|
|
blank=True,
|
|
verbose_name=_("Last Name (Arabic)")
|
|
)
|
|
|
|
# Demographics
|
|
date_of_birth = models.DateField(
|
|
verbose_name=_("Date of Birth")
|
|
)
|
|
sex = models.CharField(
|
|
max_length=1,
|
|
choices=Sex.choices,
|
|
verbose_name=_("Sex")
|
|
)
|
|
|
|
# Contact Information
|
|
phone = PhoneNumberField(
|
|
blank=True,
|
|
verbose_name=_("Phone Number")
|
|
)
|
|
email = models.EmailField(
|
|
blank=True,
|
|
verbose_name=_("Email")
|
|
)
|
|
|
|
# Caregiver Information
|
|
caregiver_name = models.CharField(
|
|
max_length=200,
|
|
blank=True,
|
|
verbose_name=_("Caregiver Name")
|
|
)
|
|
caregiver_phone = PhoneNumberField(
|
|
blank=True,
|
|
verbose_name=_("Caregiver Phone")
|
|
)
|
|
caregiver_relationship = models.CharField(
|
|
max_length=100,
|
|
blank=True,
|
|
verbose_name=_("Caregiver Relationship")
|
|
)
|
|
|
|
# Address
|
|
address = models.TextField(
|
|
blank=True,
|
|
verbose_name=_("Address")
|
|
)
|
|
city = models.CharField(
|
|
max_length=100,
|
|
blank=True,
|
|
verbose_name=_("City")
|
|
)
|
|
postal_code = models.CharField(
|
|
max_length=20,
|
|
blank=True,
|
|
verbose_name=_("Postal Code")
|
|
)
|
|
|
|
# Emergency Contact
|
|
emergency_contact = models.TextField(
|
|
blank=True,
|
|
verbose_name=_("Emergency Contact")
|
|
)
|
|
|
|
history = HistoricalRecords()
|
|
|
|
class Meta:
|
|
verbose_name = _("Patient")
|
|
verbose_name_plural = _("Patients")
|
|
ordering = ['-created_at']
|
|
indexes = [
|
|
models.Index(fields=['mrn']),
|
|
models.Index(fields=['national_id']),
|
|
models.Index(fields=['tenant', 'created_at']),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"{self.first_name_en} {self.last_name_en} ({self.mrn})"
|
|
|
|
@property
|
|
def full_name_en(self):
|
|
"""Full name in English with father and grandfather names."""
|
|
parts = [self.first_name_en]
|
|
if self.father_name_en:
|
|
parts.append(self.father_name_en)
|
|
if self.grandfather_name_en:
|
|
parts.append(self.grandfather_name_en)
|
|
parts.append(self.last_name_en)
|
|
return " ".join(parts)
|
|
|
|
@property
|
|
def full_name_ar(self):
|
|
"""Full name in Arabic with father and grandfather names."""
|
|
if not self.first_name_ar or not self.last_name_ar:
|
|
return ""
|
|
parts = [self.first_name_ar]
|
|
if self.father_name_ar:
|
|
parts.append(self.father_name_ar)
|
|
if self.grandfather_name_ar:
|
|
parts.append(self.grandfather_name_ar)
|
|
parts.append(self.last_name_ar)
|
|
return " ".join(parts)
|
|
|
|
@property
|
|
def age(self):
|
|
from datetime import date
|
|
today = date.today()
|
|
return today.year - self.date_of_birth.year - (
|
|
(today.month, today.day) < (self.date_of_birth.month, self.date_of_birth.day)
|
|
)
|
|
|
|
|
|
class Clinic(UUIDPrimaryKeyMixin, TimeStampedMixin, TenantOwnedMixin):
|
|
"""
|
|
Clinic/Department model representing different specialties.
|
|
"""
|
|
|
|
class Specialty(models.TextChoices):
|
|
MEDICAL = 'MEDICAL', _('Medical')
|
|
NURSING = 'NURSING', _('Nursing')
|
|
ABA = 'ABA', _('ABA Therapy')
|
|
OT = 'OT', _('Occupational Therapy')
|
|
SLP = 'SLP', _('Speech-Language Pathology')
|
|
|
|
name_en = models.CharField(
|
|
max_length=200,
|
|
verbose_name=_("Name (English)")
|
|
)
|
|
name_ar = models.CharField(
|
|
max_length=200,
|
|
blank=True,
|
|
verbose_name=_("Name (Arabic)")
|
|
)
|
|
specialty = models.CharField(
|
|
max_length=20,
|
|
choices=Specialty.choices,
|
|
verbose_name=_("Specialty")
|
|
)
|
|
code = models.CharField(
|
|
max_length=50,
|
|
verbose_name=_("Code")
|
|
)
|
|
is_active = models.BooleanField(
|
|
default=True,
|
|
verbose_name=_("Is Active")
|
|
)
|
|
|
|
class Meta:
|
|
verbose_name = _("Clinic")
|
|
verbose_name_plural = _("Clinics")
|
|
ordering = ['name_en']
|
|
unique_together = [['tenant', 'code']]
|
|
|
|
def __str__(self):
|
|
return f"{self.name_en} ({self.get_specialty_display()})"
|
|
|
|
|
|
class File(UUIDPrimaryKeyMixin, TimeStampedMixin, TenantOwnedMixin):
|
|
"""
|
|
Main patient file (medical record).
|
|
Auto-created when a patient is registered.
|
|
"""
|
|
|
|
class Status(models.TextChoices):
|
|
ACTIVE = 'ACTIVE', _('Active')
|
|
CLOSED = 'CLOSED', _('Closed')
|
|
ARCHIVED = 'ARCHIVED', _('Archived')
|
|
|
|
file_number = models.CharField(
|
|
max_length=20,
|
|
unique=True,
|
|
editable=False,
|
|
verbose_name=_("File Number")
|
|
)
|
|
patient = models.OneToOneField(
|
|
Patient,
|
|
on_delete=models.CASCADE,
|
|
related_name='file',
|
|
verbose_name=_("Patient")
|
|
)
|
|
opened_date = models.DateField(
|
|
auto_now_add=True,
|
|
verbose_name=_("Opened Date")
|
|
)
|
|
status = models.CharField(
|
|
max_length=20,
|
|
choices=Status.choices,
|
|
default=Status.ACTIVE,
|
|
verbose_name=_("Status")
|
|
)
|
|
|
|
class Meta:
|
|
verbose_name = _("File")
|
|
verbose_name_plural = _("Files")
|
|
ordering = ['-opened_date']
|
|
indexes = [
|
|
models.Index(fields=['file_number']),
|
|
models.Index(fields=['patient']),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"File #{self.file_number} - {self.patient}"
|
|
|
|
|
|
class SubFile(UUIDPrimaryKeyMixin, TimeStampedMixin):
|
|
"""
|
|
Sub-file per clinic/specialty.
|
|
Created when patient first visits a specific clinic.
|
|
"""
|
|
|
|
class Status(models.TextChoices):
|
|
ACTIVE = 'ACTIVE', _('Active')
|
|
CLOSED = 'CLOSED', _('Closed')
|
|
|
|
file = models.ForeignKey(
|
|
File,
|
|
on_delete=models.CASCADE,
|
|
related_name='subfiles',
|
|
verbose_name=_("File")
|
|
)
|
|
clinic = models.ForeignKey(
|
|
Clinic,
|
|
on_delete=models.CASCADE,
|
|
related_name='subfiles',
|
|
verbose_name=_("Clinic")
|
|
)
|
|
sub_file_number = models.CharField(
|
|
max_length=20,
|
|
verbose_name=_("Sub-File Number")
|
|
)
|
|
opened_date = models.DateField(
|
|
auto_now_add=True,
|
|
verbose_name=_("Opened Date")
|
|
)
|
|
status = models.CharField(
|
|
max_length=20,
|
|
choices=Status.choices,
|
|
default=Status.ACTIVE,
|
|
verbose_name=_("Status")
|
|
)
|
|
assigned_provider = models.ForeignKey(
|
|
User,
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name='assigned_subfiles',
|
|
verbose_name=_("Assigned Provider")
|
|
)
|
|
|
|
class Meta:
|
|
verbose_name = _("Sub-File")
|
|
verbose_name_plural = _("Sub-Files")
|
|
ordering = ['-opened_date']
|
|
unique_together = [['file', 'clinic']]
|
|
|
|
def __str__(self):
|
|
return f"SubFile #{self.sub_file_number} - {self.clinic.name_en}"
|
|
|
|
|
|
class ConsentTemplate(UUIDPrimaryKeyMixin, TimeStampedMixin, TenantOwnedMixin):
|
|
"""
|
|
Predefined consent text templates for different consent types.
|
|
"""
|
|
|
|
class ConsentType(models.TextChoices):
|
|
GENERAL_TREATMENT = 'GENERAL_TREATMENT', _('General Treatment')
|
|
SERVICE_SPECIFIC = 'SERVICE_SPECIFIC', _('Service Specific')
|
|
PHOTO_VIDEO = 'PHOTO_VIDEO', _('Photo/Video')
|
|
DATA_SHARING = 'DATA_SHARING', _('Data Sharing')
|
|
|
|
consent_type = models.CharField(
|
|
max_length=30,
|
|
choices=ConsentType.choices,
|
|
verbose_name=_("Consent Type")
|
|
)
|
|
title_en = models.CharField(
|
|
max_length=200,
|
|
verbose_name=_("Title (English)")
|
|
)
|
|
title_ar = models.CharField(
|
|
max_length=200,
|
|
blank=True,
|
|
verbose_name=_("Title (Arabic)")
|
|
)
|
|
content_en = models.TextField(
|
|
verbose_name=_("Content (English)"),
|
|
help_text=_("Consent text in English. Use {patient_name}, {patient_mrn}, {date} as placeholders.")
|
|
)
|
|
content_ar = models.TextField(
|
|
blank=True,
|
|
verbose_name=_("Content (Arabic)"),
|
|
help_text=_("Consent text in Arabic. Use {patient_name}, {patient_mrn}, {date} as placeholders.")
|
|
)
|
|
is_active = models.BooleanField(
|
|
default=True,
|
|
verbose_name=_("Is Active")
|
|
)
|
|
version = models.PositiveIntegerField(
|
|
default=1,
|
|
verbose_name=_("Version")
|
|
)
|
|
|
|
class Meta:
|
|
verbose_name = _("Consent Template")
|
|
verbose_name_plural = _("Consent Templates")
|
|
ordering = ['consent_type', '-version']
|
|
unique_together = [['tenant', 'consent_type', 'version']]
|
|
|
|
def __str__(self):
|
|
return f"{self.get_consent_type_display()} - v{self.version}"
|
|
|
|
def get_populated_content(self, patient, language='en'):
|
|
"""
|
|
Populate template with patient data.
|
|
|
|
Args:
|
|
patient: Patient instance
|
|
language: 'en' or 'ar'
|
|
|
|
Returns:
|
|
Populated consent text
|
|
"""
|
|
from django.utils import timezone
|
|
|
|
content = self.content_en if language == 'en' else self.content_ar
|
|
if not content:
|
|
content = self.content_en # Fallback to English
|
|
|
|
# Prepare replacement values
|
|
replacements = {
|
|
'{patient_name}': patient.full_name_en if language == 'en' else patient.full_name_ar or patient.full_name_en,
|
|
'{patient_mrn}': patient.mrn,
|
|
'{date}': timezone.now().strftime('%Y-%m-%d'),
|
|
'{patient_dob}': patient.date_of_birth.strftime('%Y-%m-%d'),
|
|
'{patient_age}': str(patient.age),
|
|
}
|
|
|
|
# Replace placeholders
|
|
for placeholder, value in replacements.items():
|
|
content = content.replace(placeholder, value)
|
|
|
|
return content
|
|
|
|
|
|
class Consent(UUIDPrimaryKeyMixin, TimeStampedMixin, TenantOwnedMixin):
|
|
"""
|
|
Consent forms with e-signature support.
|
|
"""
|
|
|
|
class ConsentType(models.TextChoices):
|
|
GENERAL_TREATMENT = 'GENERAL_TREATMENT', _('General Treatment')
|
|
SERVICE_SPECIFIC = 'SERVICE_SPECIFIC', _('Service Specific')
|
|
PHOTO_VIDEO = 'PHOTO_VIDEO', _('Photo/Video')
|
|
DATA_SHARING = 'DATA_SHARING', _('Data Sharing')
|
|
|
|
class SignatureMethod(models.TextChoices):
|
|
DRAWN = 'DRAWN', _('Drawn')
|
|
TYPED = 'TYPED', _('Typed')
|
|
UPLOADED = 'UPLOADED', _('Uploaded')
|
|
EXTERNAL = 'EXTERNAL', _('External Provider')
|
|
|
|
patient = models.ForeignKey(
|
|
Patient,
|
|
on_delete=models.CASCADE,
|
|
related_name='consents',
|
|
verbose_name=_("Patient")
|
|
)
|
|
consent_type = models.CharField(
|
|
max_length=30,
|
|
choices=ConsentType.choices,
|
|
verbose_name=_("Consent Type")
|
|
)
|
|
content_text = models.TextField(
|
|
verbose_name=_("Content Text")
|
|
)
|
|
|
|
# Signature Information
|
|
signed_by_name = models.CharField(
|
|
max_length=200,
|
|
blank=True,
|
|
verbose_name=_("Signed By Name")
|
|
)
|
|
signed_by_relationship = models.CharField(
|
|
max_length=100,
|
|
blank=True,
|
|
verbose_name=_("Relationship to Patient")
|
|
)
|
|
signed_at = models.DateTimeField(
|
|
null=True,
|
|
blank=True,
|
|
verbose_name=_("Signed At")
|
|
)
|
|
signature_method = models.CharField(
|
|
max_length=20,
|
|
choices=SignatureMethod.choices,
|
|
blank=True,
|
|
verbose_name=_("Signature Method")
|
|
)
|
|
signature_image = models.ImageField(
|
|
upload_to='consents/signatures/',
|
|
null=True,
|
|
blank=True,
|
|
verbose_name=_("Signature Image")
|
|
)
|
|
signature_hash = models.CharField(
|
|
max_length=64,
|
|
blank=True,
|
|
verbose_name=_("Signature Hash")
|
|
)
|
|
signed_ip = models.GenericIPAddressField(
|
|
null=True,
|
|
blank=True,
|
|
verbose_name=_("Signed IP Address")
|
|
)
|
|
signed_user_agent = models.TextField(
|
|
blank=True,
|
|
verbose_name=_("Signed User Agent")
|
|
)
|
|
|
|
# Version Control
|
|
version = models.PositiveIntegerField(
|
|
default=1,
|
|
verbose_name=_("Version")
|
|
)
|
|
is_active = models.BooleanField(
|
|
default=True,
|
|
verbose_name=_("Is Active")
|
|
)
|
|
|
|
history = HistoricalRecords()
|
|
|
|
class Meta:
|
|
verbose_name = _("Consent")
|
|
verbose_name_plural = _("Consents")
|
|
ordering = ['-created_at']
|
|
indexes = [
|
|
models.Index(fields=['patient', 'consent_type']),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"{self.get_consent_type_display()} - {self.patient}"
|
|
|
|
|
|
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)
|
|
|
|
|
|
class Attachment(UUIDPrimaryKeyMixin, TimeStampedMixin, TenantOwnedMixin):
|
|
"""
|
|
Generic attachment model for any document.
|
|
Uses GenericForeignKey to attach to any model.
|
|
"""
|
|
|
|
class FileType(models.TextChoices):
|
|
PDF = 'PDF', _('PDF')
|
|
IMAGE = 'IMAGE', _('Image')
|
|
DOCUMENT = 'DOCUMENT', _('Document')
|
|
OTHER = 'OTHER', _('Other')
|
|
|
|
# Generic Foreign Key
|
|
content_type = models.ForeignKey(
|
|
ContentType,
|
|
on_delete=models.CASCADE,
|
|
verbose_name=_("Content Type")
|
|
)
|
|
object_id = models.UUIDField(
|
|
verbose_name=_("Object ID")
|
|
)
|
|
content_object = GenericForeignKey('content_type', 'object_id')
|
|
|
|
file = models.FileField(
|
|
upload_to='attachments/%Y/%m/%d/',
|
|
verbose_name=_("File")
|
|
)
|
|
file_type = models.CharField(
|
|
max_length=20,
|
|
choices=FileType.choices,
|
|
verbose_name=_("File Type")
|
|
)
|
|
description = models.TextField(
|
|
blank=True,
|
|
verbose_name=_("Description")
|
|
)
|
|
uploaded_by = models.ForeignKey(
|
|
User,
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
related_name='uploaded_attachments',
|
|
verbose_name=_("Uploaded By")
|
|
)
|
|
|
|
class Meta:
|
|
verbose_name = _("Attachment")
|
|
verbose_name_plural = _("Attachments")
|
|
ordering = ['-created_at']
|
|
indexes = [
|
|
models.Index(fields=['content_type', 'object_id']),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"{self.file.name} ({self.get_file_type_display()})"
|
|
|
|
|
|
class AuditLog(UUIDPrimaryKeyMixin, TenantOwnedMixin):
|
|
"""
|
|
Audit log for tracking all actions in the system.
|
|
Complements simple_history for additional tracking.
|
|
"""
|
|
|
|
class Action(models.TextChoices):
|
|
CREATE = 'CREATE', _('Create')
|
|
UPDATE = 'UPDATE', _('Update')
|
|
DELETE = 'DELETE', _('Delete')
|
|
VIEW = 'VIEW', _('View')
|
|
EXPORT = 'EXPORT', _('Export')
|
|
PRINT = 'PRINT', _('Print')
|
|
|
|
# Generic Foreign Key
|
|
content_type = models.ForeignKey(
|
|
ContentType,
|
|
on_delete=models.CASCADE,
|
|
null=True,
|
|
blank=True,
|
|
verbose_name=_("Content Type")
|
|
)
|
|
object_id = models.UUIDField(
|
|
null=True,
|
|
blank=True,
|
|
verbose_name=_("Object ID")
|
|
)
|
|
content_object = GenericForeignKey('content_type', 'object_id')
|
|
|
|
action = models.CharField(
|
|
max_length=20,
|
|
choices=Action.choices,
|
|
verbose_name=_("Action")
|
|
)
|
|
user = models.ForeignKey(
|
|
User,
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
related_name='audit_logs',
|
|
verbose_name=_("User")
|
|
)
|
|
timestamp = models.DateTimeField(
|
|
auto_now_add=True,
|
|
verbose_name=_("Timestamp")
|
|
)
|
|
changes = models.JSONField(
|
|
default=dict,
|
|
blank=True,
|
|
verbose_name=_("Changes")
|
|
)
|
|
ip_address = models.GenericIPAddressField(
|
|
null=True,
|
|
blank=True,
|
|
verbose_name=_("IP Address")
|
|
)
|
|
|
|
class Meta:
|
|
verbose_name = _("Audit Log")
|
|
verbose_name_plural = _("Audit Logs")
|
|
ordering = ['-timestamp']
|
|
indexes = [
|
|
models.Index(fields=['content_type', 'object_id']),
|
|
models.Index(fields=['user', 'timestamp']),
|
|
models.Index(fields=['action', 'timestamp']),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"{self.user} - {self.get_action_display()} - {self.timestamp}"
|
|
|
|
|
|
class SettingTemplate(UUIDPrimaryKeyMixin, TimeStampedMixin):
|
|
"""
|
|
Template defining available tenant settings with validation rules.
|
|
Defines the structure and constraints for tenant configuration.
|
|
"""
|
|
|
|
class Category(models.TextChoices):
|
|
BASIC = 'BASIC', _('Basic Information')
|
|
VAT = 'VAT', _('VAT Registration')
|
|
ADDRESS = 'ADDRESS', _('Address Information')
|
|
ZATCA = 'ZATCA', _('ZATCA E-Invoicing')
|
|
NPHIES = 'NPHIES', _('NPHIES Integration')
|
|
SMS = 'SMS', _('SMS/WhatsApp Integration')
|
|
LAB = 'LAB', _('Lab Integration')
|
|
RADIOLOGY = 'RADIOLOGY', _('Radiology Integration')
|
|
|
|
class DataType(models.TextChoices):
|
|
STRING = 'STRING', _('String')
|
|
INTEGER = 'INTEGER', _('Integer')
|
|
BOOLEAN = 'BOOLEAN', _('Boolean')
|
|
CHOICE = 'CHOICE', _('Choice')
|
|
FILE = 'FILE', _('File')
|
|
ENCRYPTED = 'ENCRYPTED', _('Encrypted')
|
|
TEXT = 'TEXT', _('Text (Multi-line)')
|
|
EMAIL = 'EMAIL', _('Email')
|
|
URL = 'URL', _('URL')
|
|
PHONE = 'PHONE', _('Phone Number')
|
|
COLOR = 'COLOR', _('Color')
|
|
|
|
key = models.CharField(
|
|
max_length=100,
|
|
unique=True,
|
|
verbose_name=_("Key"),
|
|
help_text=_("Unique identifier for this setting (e.g., 'basic_clinic_name_en')")
|
|
)
|
|
category = models.CharField(
|
|
max_length=20,
|
|
choices=Category.choices,
|
|
verbose_name=_("Category")
|
|
)
|
|
label_en = models.CharField(
|
|
max_length=200,
|
|
verbose_name=_("Label (English)")
|
|
)
|
|
label_ar = models.CharField(
|
|
max_length=200,
|
|
blank=True,
|
|
verbose_name=_("Label (Arabic)")
|
|
)
|
|
data_type = models.CharField(
|
|
max_length=20,
|
|
choices=DataType.choices,
|
|
verbose_name=_("Data Type")
|
|
)
|
|
is_required = models.BooleanField(
|
|
default=False,
|
|
verbose_name=_("Is Required"),
|
|
help_text=_("Whether this setting must be provided")
|
|
)
|
|
default_value = models.TextField(
|
|
blank=True,
|
|
verbose_name=_("Default Value")
|
|
)
|
|
help_text_en = models.TextField(
|
|
blank=True,
|
|
verbose_name=_("Help Text (English)")
|
|
)
|
|
help_text_ar = models.TextField(
|
|
blank=True,
|
|
verbose_name=_("Help Text (Arabic)")
|
|
)
|
|
validation_regex = models.CharField(
|
|
max_length=500,
|
|
blank=True,
|
|
verbose_name=_("Validation Regex"),
|
|
help_text=_("Regular expression for validation (optional)")
|
|
)
|
|
choices = models.JSONField(
|
|
default=list,
|
|
blank=True,
|
|
verbose_name=_("Choices"),
|
|
help_text=_("List of choices for CHOICE data type (e.g., [{'value': 'opt1', 'label': 'Option 1'}])")
|
|
)
|
|
order = models.PositiveIntegerField(
|
|
default=0,
|
|
verbose_name=_("Display Order"),
|
|
help_text=_("Order in which this setting appears in the form")
|
|
)
|
|
is_active = models.BooleanField(
|
|
default=True,
|
|
verbose_name=_("Is Active")
|
|
)
|
|
|
|
class Meta:
|
|
verbose_name = _("Setting Template")
|
|
verbose_name_plural = _("Setting Templates")
|
|
ordering = ['category', 'order', 'label_en']
|
|
indexes = [
|
|
models.Index(fields=['category', 'order']),
|
|
models.Index(fields=['key']),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"{self.get_category_display()} - {self.label_en}"
|
|
|
|
|
|
class TenantSetting(UUIDPrimaryKeyMixin, TimeStampedMixin):
|
|
"""
|
|
Actual setting values for each tenant.
|
|
Stores configuration based on SettingTemplate definitions.
|
|
"""
|
|
|
|
tenant = models.ForeignKey(
|
|
Tenant,
|
|
on_delete=models.CASCADE,
|
|
related_name='tenant_settings',
|
|
verbose_name=_("Tenant")
|
|
)
|
|
template = models.ForeignKey(
|
|
SettingTemplate,
|
|
on_delete=models.CASCADE,
|
|
related_name='tenant_values',
|
|
verbose_name=_("Setting Template")
|
|
)
|
|
value = models.TextField(
|
|
blank=True,
|
|
verbose_name=_("Value"),
|
|
help_text=_("Stores string, integer, boolean, and choice values")
|
|
)
|
|
encrypted_value = models.BinaryField(
|
|
null=True,
|
|
blank=True,
|
|
verbose_name=_("Encrypted Value"),
|
|
help_text=_("Stores encrypted sensitive data (API keys, tokens, etc.)")
|
|
)
|
|
file_value = models.FileField(
|
|
upload_to='tenant_settings/%Y/%m/%d/',
|
|
null=True,
|
|
blank=True,
|
|
verbose_name=_("File Value"),
|
|
help_text=_("Stores file uploads (logos, certificates, etc.)")
|
|
)
|
|
updated_by = models.ForeignKey(
|
|
User,
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name='updated_settings',
|
|
verbose_name=_("Updated By")
|
|
)
|
|
|
|
history = HistoricalRecords()
|
|
|
|
class Meta:
|
|
verbose_name = _("Tenant Setting")
|
|
verbose_name_plural = _("Tenant Settings")
|
|
ordering = ['tenant', 'template__category', 'template__order']
|
|
unique_together = [['tenant', 'template']]
|
|
indexes = [
|
|
models.Index(fields=['tenant', 'template']),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"{self.tenant.name} - {self.template.label_en}"
|
|
|
|
def get_typed_value(self):
|
|
"""
|
|
Returns the value converted to the appropriate type based on template data_type.
|
|
"""
|
|
if self.template.data_type == SettingTemplate.DataType.BOOLEAN:
|
|
return self.value.lower() in ('true', '1', 'yes') if self.value else False
|
|
elif self.template.data_type == SettingTemplate.DataType.INTEGER:
|
|
try:
|
|
return int(self.value) if self.value else None
|
|
except (ValueError, TypeError):
|
|
return None
|
|
elif self.template.data_type == SettingTemplate.DataType.FILE:
|
|
return self.file_value
|
|
elif self.template.data_type == SettingTemplate.DataType.ENCRYPTED:
|
|
# Will be decrypted by the service layer
|
|
return self.encrypted_value
|
|
else:
|
|
return self.value
|
|
|
|
|
|
class ContactMessage(UUIDPrimaryKeyMixin, TimeStampedMixin):
|
|
"""
|
|
Contact form submissions from the landing page.
|
|
|
|
Stores inquiries from potential clients and visitors.
|
|
Admins are notified via email and in-app notifications.
|
|
"""
|
|
|
|
name = models.CharField(
|
|
max_length=200,
|
|
verbose_name=_("Name")
|
|
)
|
|
email = models.EmailField(
|
|
verbose_name=_("Email")
|
|
)
|
|
phone = PhoneNumberField(
|
|
blank=True,
|
|
verbose_name=_("Phone Number")
|
|
)
|
|
message = models.TextField(
|
|
verbose_name=_("Message")
|
|
)
|
|
|
|
# Metadata for security and tracking
|
|
submitted_at = models.DateTimeField(
|
|
auto_now_add=True,
|
|
verbose_name=_("Submitted At")
|
|
)
|
|
ip_address = models.GenericIPAddressField(
|
|
null=True,
|
|
blank=True,
|
|
verbose_name=_("IP Address")
|
|
)
|
|
user_agent = models.TextField(
|
|
blank=True,
|
|
verbose_name=_("User Agent"),
|
|
help_text=_("Browser and device information")
|
|
)
|
|
|
|
# Admin tracking
|
|
is_read = models.BooleanField(
|
|
default=False,
|
|
verbose_name=_("Is Read"),
|
|
help_text=_("Whether an admin has viewed this message")
|
|
)
|
|
responded_at = models.DateTimeField(
|
|
null=True,
|
|
blank=True,
|
|
verbose_name=_("Responded At")
|
|
)
|
|
responded_by = models.ForeignKey(
|
|
User,
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name='responded_contact_messages',
|
|
verbose_name=_("Responded By")
|
|
)
|
|
notes = models.TextField(
|
|
blank=True,
|
|
verbose_name=_("Admin Notes"),
|
|
help_text=_("Internal notes about this inquiry")
|
|
)
|
|
|
|
class Meta:
|
|
verbose_name = _("Contact Message")
|
|
verbose_name_plural = _("Contact Messages")
|
|
ordering = ['-submitted_at']
|
|
indexes = [
|
|
models.Index(fields=['email']),
|
|
models.Index(fields=['is_read', 'submitted_at']),
|
|
models.Index(fields=['submitted_at']),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"{self.name} - {self.email} ({self.submitted_at.strftime('%Y-%m-%d %H:%M')})"
|
|
|
|
def mark_as_read(self):
|
|
"""Mark this message as read."""
|
|
if not self.is_read:
|
|
self.is_read = True
|
|
self.save(update_fields=['is_read'])
|
|
|
|
def mark_as_responded(self, user):
|
|
"""Mark this message as responded to."""
|
|
from django.utils import timezone
|
|
self.responded_at = timezone.now()
|
|
self.responded_by = user
|
|
self.is_read = True
|
|
self.save(update_fields=['responded_at', 'responded_by', 'is_read'])
|