agdar/core/models.py
Marwan Alwali 2f1681b18c update
2025-11-11 13:44:48 +03:00

1310 lines
37 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")
)
# Expiry Management
expiry_date = models.DateField(
null=True,
blank=True,
verbose_name=_("Expiry Date"),
help_text=_("Date when this consent expires and needs renewal")
)
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}"
@property
def is_expired(self):
"""Check if consent has expired."""
if not self.expiry_date:
return False
from datetime import date
return date.today() > self.expiry_date
@property
def days_until_expiry(self):
"""Calculate days until consent expires."""
if not self.expiry_date:
return None
from datetime import date
delta = self.expiry_date - date.today()
return delta.days
@property
def needs_renewal(self):
"""Check if consent needs renewal (within 30 days of expiry or expired)."""
if not self.expiry_date:
return False
days = self.days_until_expiry
return days is not None and days <= 30
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'])