802 lines
35 KiB
Python
802 lines
35 KiB
Python
import random
|
||
import uuid
|
||
from datetime import datetime, date, time, timedelta
|
||
from collections import defaultdict
|
||
|
||
from django.core.management.base import BaseCommand, CommandError
|
||
from django.db import transaction, IntegrityError
|
||
from django.db.models import Q
|
||
from django.conf import settings
|
||
from django.utils import timezone
|
||
|
||
# Import your models
|
||
from core.models import Tenant
|
||
from operating_theatre.models import (
|
||
OperatingRoom, ORBlock, SurgicalCase, SurgicalNote,
|
||
EquipmentUsage, SurgicalNoteTemplate
|
||
)
|
||
from patients.models import PatientProfile
|
||
from emr.models import Encounter
|
||
from inpatients.models import Admission
|
||
from django.contrib.auth import get_user_model
|
||
|
||
User = get_user_model()
|
||
|
||
# ---------- Saudi-influenced constants (English only) ----------
|
||
|
||
SAUDI_BUILDINGS = [
|
||
"King Abdulaziz Tower A", "King Abdulaziz Tower B",
|
||
"Riyadh Medical Pavilion", "Jeddah Surgical Center",
|
||
"Makkah Clinical Wing", "Dammam Health Complex",
|
||
"Madina Specialty Institute", "Qassim Medical Block",
|
||
]
|
||
|
||
SAUDI_WINGS = ["North Wing", "South Wing", "East Wing", "West Wing", "Royal Wing"]
|
||
|
||
# Services / specialties mapped to common procedure examples
|
||
SERVICE_TO_PROCEDURES = {
|
||
"GENERAL": [
|
||
("Laparoscopic Cholecystectomy", ["SILS Appendectomy"], ["47562"]),
|
||
("Open Appendectomy", ["Diagnostic Laparoscopy"], ["44950"]),
|
||
("Inguinal Hernia Repair", ["Umbilical Hernia Repair"], ["49505"]),
|
||
],
|
||
"CARDIAC": [
|
||
("CABG x3", ["Saphenous Vein Harvest"], ["33512"]),
|
||
("Mitral Valve Repair", ["Left Atrial Appendage Closure"], ["33426"]),
|
||
("Aortic Valve Replacement", ["Ascending Aorta Repair"], ["33405"]),
|
||
],
|
||
"NEURO": [
|
||
("Craniotomy for Tumor", ["External Ventricular Drain"], ["61510"]),
|
||
("Lumbar Microdiscectomy", ["Foraminotomy"], ["63030"]),
|
||
("Aneurysm Clipping", ["Intraop Angiography"], ["61697"]),
|
||
],
|
||
"ORTHOPEDIC": [
|
||
("Total Knee Arthroplasty", ["Patellar Resurfacing"], ["27447"]),
|
||
("Hip Hemiarthroplasty", ["Acetabular Revision"], ["27125"]),
|
||
("ACL Reconstruction", ["Meniscectomy"], ["29888"]),
|
||
],
|
||
"TRAUMA": [
|
||
("Open Reduction Internal Fixation - Tibia", ["External Fixator Removal"], ["27758"]),
|
||
("Exploratory Laparotomy", ["Splenectomy"], ["49000"]),
|
||
("Thoracotomy for Hemothorax", ["Rib Fixation"], ["32100"]),
|
||
],
|
||
"PEDIATRIC": [
|
||
("Pyloromyotomy", ["Umbilical Hernia Repair"], ["43520"]),
|
||
("Orchiopexy", ["Inguinal Hernia Repair"], ["54640"]),
|
||
("Tonsillectomy & Adenoidectomy", ["Myringotomy"], ["42820"]),
|
||
],
|
||
"OBSTETRIC": [
|
||
("Emergency Cesarean Section", ["B-Lynch Suture"], ["59510"]),
|
||
("Elective Cesarean Section", ["Tubal Ligation"], ["59514"]),
|
||
("Manual Removal of Placenta", ["Uterine Curettage"], ["59414"]),
|
||
],
|
||
"OPHTHALMOLOGY": [
|
||
("Phacoemulsification with IOL", ["Trabeculectomy"], ["66984"]),
|
||
("Retinal Detachment Repair", ["Laser Photocoagulation"], ["67108"]),
|
||
("Strabismus Surgery", ["Adjustable Sutures"], ["67311"]),
|
||
],
|
||
"ENT": [
|
||
("Functional Endoscopic Sinus Surgery", ["Septoplasty"], ["31253"]),
|
||
("Microlaryngoscopy", ["Polypectomy"], ["31526"]),
|
||
("Tympanoplasty", ["Mastoidectomy"], ["69631"]),
|
||
],
|
||
"UROLOGY": [
|
||
("TURP", ["Cystoscopy"], ["52601"]),
|
||
("PCNL", ["DJ Stent Placement"], ["50080"]),
|
||
("Laparoscopic Nephrectomy", ["Adrenalectomy"], ["50545"]),
|
||
],
|
||
"PLASTIC": [
|
||
("Split-Thickness Skin Graft", ["Debridement"], ["15100"]),
|
||
("Breast Reconstruction (DIEP)", ["Implant Exchange"], ["19357"]),
|
||
("Cleft Lip Repair", ["Cleft Palate Repair"], ["40701"]),
|
||
],
|
||
"VASCULAR": [
|
||
("AV Fistula Creation", ["Angioplasty"], ["36821"]),
|
||
("Carotid Endarterectomy", ["Patch Angioplasty"], ["35301"]),
|
||
("EVAR", ["Iliac Extension"], ["34802"]),
|
||
],
|
||
"THORACIC": [
|
||
("VATS Lobectomy", ["Lymph Node Dissection"], ["32663"]),
|
||
("Esophagectomy", ["Feeding Jejunostomy"], ["43107"]),
|
||
("Mediastinoscopy", ["Bronchoscopy"], ["39402"]),
|
||
],
|
||
"TRANSPLANT": [
|
||
("Living Donor Kidney Transplant", ["Ureteric Stent"], ["50360"]),
|
||
("Liver Transplant", ["Biliary Reconstruction"], ["47135"]),
|
||
("Pancreas Transplant", ["Enteric Drainage"], ["48554"]),
|
||
],
|
||
}
|
||
|
||
ROOM_FEATURES = [
|
||
"Laminar Flow", "HEPA Filtration", "Lead-Lined Walls", "Integrated Boom",
|
||
"Ceiling-Mounted Monitors", "Shadowless Lighting", "Dedicated Anesthesia Column",
|
||
"Sterile Core Access", "Hybrid Imaging Suite", "Negative Pressure Anteroom"
|
||
]
|
||
|
||
EQUIPMENT_BANK = [
|
||
# (name, type, manufacturer, model)
|
||
("Dräger Anesthesia Machine", "ANESTHESIA_MACHINE", "Dräger", "Fabius Plus"),
|
||
("GE Patient Monitor", "MONITORING_DEVICE", "GE Healthcare", "CARESCAPE B650"),
|
||
("Valleylab Electrocautery", "ELECTROCAUTERY", "Medtronic", "ForceTriad"),
|
||
("Karl Storz Laparoscopy Set", "SURGICAL_INSTRUMENT", "Karl Storz", "Image1 S"),
|
||
("Stryker 1688 AIM Camera", "SURGICAL_INSTRUMENT", "Stryker", "1688 AIM"),
|
||
("Zeiss Surgical Microscope", "MICROSCOPE", "Zeiss", "OPMI Pentero"),
|
||
("C-Arm Fluoroscopy", "C_ARM", "Siemens", "Cios Fusion"),
|
||
("Portable Ultrasound", "ULTRASOUND", "Mindray", "M9"),
|
||
("Da Vinci Surgical Robot", "ROBOT", "Intuitive Surgical", "Xi"),
|
||
]
|
||
|
||
BLOOD_PRODUCTS = ["PRBC", "FFP", "Platelets", "Cryoprecipitate"]
|
||
IMPLANT_EXAMPLES = ["Titanium Plate", "Pedicle Screws", "Hemashield Patch", "Stent Graft", "Knee Prosthesis"]
|
||
|
||
DIAGNOSES = [
|
||
("Cholelithiasis with Cholecystitis", ["K80.10"]),
|
||
("Degenerative Joint Disease Knee", ["M17.10"]),
|
||
("Intracranial Neoplasm", ["D49.6"]),
|
||
("Coronary Artery Disease", ["I25.10"]),
|
||
("Ureteric Calculus", ["N20.1"]),
|
||
("Pelvic Organ Prolapse", ["N81.4"]),
|
||
("Retinal Detachment", ["H33.2"]),
|
||
("Carotid Stenosis", ["I65.21"]),
|
||
]
|
||
|
||
# Operating hours (typical KSA elective schedule) and emergency window
|
||
ELECTIVE_START = time(7, 30)
|
||
ELECTIVE_END = time(15, 30)
|
||
EMERGENCY_START = time(16, 0)
|
||
EMERGENCY_END = time(22, 0)
|
||
|
||
# ---------- Helpers ----------
|
||
|
||
def choose_staff(candidates, k=1, exclude=None):
|
||
qs = candidates
|
||
if exclude:
|
||
qs = qs.exclude(id__in=[u.id for u in exclude])
|
||
lst = list(qs)
|
||
if not lst:
|
||
return []
|
||
random.shuffle(lst)
|
||
return lst[:k]
|
||
|
||
def dt_in_ksa(d, t):
|
||
"""Return a timezone-aware datetime in the current Django timezone (e.g., Asia/Riyadh)."""
|
||
tz = timezone.get_current_timezone()
|
||
naive_dt = datetime.combine(d, t)
|
||
# If Django gave us an aware time elsewhere, be safe:
|
||
if timezone.is_naive(naive_dt):
|
||
return timezone.make_aware(naive_dt, tz)
|
||
return naive_dt.astimezone(tz)
|
||
|
||
def minutes_between(t1: time, t2: time):
|
||
d1 = datetime.combine(date.today(), t1)
|
||
d2 = datetime.combine(date.today(), t2)
|
||
if d2 < d1:
|
||
d2 += timedelta(days=1)
|
||
return int((d2 - d1).total_seconds() // 60)
|
||
|
||
def safe_get_encounter(patient):
|
||
# Prefer start_datetime; fall back to created_at, then id
|
||
return (
|
||
Encounter.objects
|
||
.filter(patient=patient)
|
||
.order_by('-start_datetime', '-created_at', '-id')
|
||
.first()
|
||
)
|
||
|
||
def safe_get_admission(patient):
|
||
# We don’t know your Admission timestamp field; use created_at/id as stable fallbacks
|
||
return (
|
||
Admission.objects
|
||
.filter(patient=patient)
|
||
.order_by('-created_at', '-id')
|
||
.first()
|
||
)
|
||
|
||
def probable_case_type(service: str):
|
||
# Slight emergency skew for Trauma/Transplant/Obstetric
|
||
if service in {"TRAUMA", "TRANSPLANT", "OBSTETRIC"}:
|
||
return random.choices(
|
||
["EMERGENCY", "URGENT", "ELECTIVE", "TRAUMA"],
|
||
weights=[40, 25, 20, 15], k=1
|
||
)[0]
|
||
return random.choices(
|
||
["ELECTIVE", "URGENT", "EMERGENCY"],
|
||
weights=[70, 20, 10], k=1
|
||
)[0]
|
||
|
||
def pick_approach(service: str):
|
||
options = ["OPEN", "LAPAROSCOPIC", "ROBOTIC", "ENDOSCOPIC", "PERCUTANEOUS", "HYBRID"]
|
||
if service in {"GENERAL", "UROLOGY", "GYNE", "OBSTETRIC"}:
|
||
weights = [25, 50, 10, 10, 3, 2]
|
||
elif service in {"ORTHOPEDIC", "NEURO", "CARDIAC", "THORACIC"}:
|
||
weights = [55, 10, 15, 5, 5, 10]
|
||
else:
|
||
weights = [50, 15, 10, 15, 5, 5]
|
||
return random.choices(options, weights=weights, k=1)[0]
|
||
|
||
def pick_anesthesia():
|
||
return random.choice(["GENERAL", "REGIONAL", "LOCAL", "SEDATION", "SPINAL", "EPIDURAL", "COMBINED"])
|
||
|
||
def pick_position(service: str):
|
||
mapping = {
|
||
"GENERAL": ["SUPINE", "LITHOTOMY", "TRENDELENBURG", "REVERSE_TREND"],
|
||
"ORTHOPEDIC": ["SUPINE", "PRONE", "LATERAL", "SITTING"],
|
||
"NEURO": ["SUPINE", "PRONE", "SITTING"],
|
||
"CARDIAC": ["SUPINE", "REVERSE_TREND"],
|
||
"UROLOGY": ["SUPINE", "LITHOTOMY"],
|
||
"ENT": ["SUPINE"],
|
||
"OPHTHALMOLOGY": ["SUPINE"],
|
||
"THORACIC": ["LATERAL", "SUPINE"],
|
||
"TRAUMA": ["SUPINE", "PRONE", "LATERAL"],
|
||
"PEDIATRIC": ["SUPINE"],
|
||
"OBSTETRIC": ["SUPINE", "LITHOTOMY"],
|
||
"PLASTIC": ["SUPINE", "PRONE", "LATERAL"],
|
||
"VASCULAR": ["SUPINE"],
|
||
"TRANSPLANT": ["SUPINE"],
|
||
}
|
||
return random.choice(mapping.get(service, ["SUPINE"]))
|
||
|
||
def random_equipment_set(service: str, robotic=False, has_c_arm=False, has_ultrasound=False):
|
||
chosen = []
|
||
pool = list(EQUIPMENT_BANK)
|
||
random.shuffle(pool)
|
||
|
||
# Always include a monitor & cautery
|
||
core = [e for e in pool if e[1] in {"MONITORING_DEVICE", "ELECTROCAUTERY", "ANESTHESIA_MACHINE"}]
|
||
chosen.extend(core[:3])
|
||
|
||
if has_c_arm:
|
||
chosen += [e for e in pool if e[1] == "C_ARM"][:1]
|
||
if has_ultrasound:
|
||
chosen += [e for e in pool if e[1] == "ULTRASOUND"][:1]
|
||
if robotic:
|
||
chosen += [e for e in pool if e[1] == "ROBOT"][:1]
|
||
|
||
# Add service-specific instrument set
|
||
instr = [e for e in pool if e[1] == "SURGICAL_INSTRUMENT"]
|
||
chosen.extend(instr[:2])
|
||
|
||
# Deduplicate by name
|
||
final = []
|
||
seen = set()
|
||
for e in chosen:
|
||
if e[0] not in seen:
|
||
final.append(e)
|
||
seen.add(e[0])
|
||
return final
|
||
|
||
# ---------- Command ----------
|
||
|
||
class Command(BaseCommand):
|
||
help = "Generate Saudi-influenced operating theatre data (English only)."
|
||
|
||
def add_arguments(self, parser):
|
||
parser.add_argument("--tenant-id", type=str, help="Tenant UUID or integer PK")
|
||
parser.add_argument("--days", type=int, default=5, help="Number of days to populate (default: 5)")
|
||
parser.add_argument("--rooms", type=int, default=6, help="Number of operating rooms to create if none exist")
|
||
parser.add_argument("--elective-blocks", type=int, default=6, help="Elective blocks per day across rooms (approx)")
|
||
parser.add_argument("--emergency-blocks", type=int, default=2, help="Emergency blocks per day (approx)")
|
||
parser.add_argument("--min-cases", type=int, default=1, help="Min cases per block")
|
||
parser.add_argument("--max-cases", type=int, default=3, help="Max cases per block")
|
||
parser.add_argument("--seed", type=int, default=None, help="Random seed for reproducibility")
|
||
parser.add_argument("--start-date", type=str, default=None, help="YYYY-MM-DD (defaults to today)")
|
||
parser.add_argument("--dry-run", action="store_true", help="Show what would be created without committing")
|
||
|
||
def handle(self, *args, **options):
|
||
if options["seed"] is not None:
|
||
random.seed(options["seed"])
|
||
|
||
# Resolve tenant
|
||
tenant_id = options.get("tenant_id")
|
||
tenant = self._get_tenant(tenant_id)
|
||
|
||
# Resolve date range
|
||
if options["start_date"]:
|
||
try:
|
||
start_date = date.fromisoformat(options["start_date"])
|
||
except Exception:
|
||
raise CommandError("Invalid --start-date. Use YYYY-MM-DD.")
|
||
else:
|
||
start_date = timezone.localdate()
|
||
|
||
days = max(1, options["days"])
|
||
rooms_target = max(1, options["rooms"])
|
||
elective_blocks = max(0, options["elective_blocks"])
|
||
emergency_blocks = max(0, options["emergency_blocks"])
|
||
min_cases = max(1, options["min_cases"])
|
||
max_cases = max(min_cases, options["max_cases"])
|
||
|
||
# Staff & patients pool
|
||
surgeons = User.objects.filter(is_active=True)
|
||
anesth_pool = User.objects.filter(is_active=True)
|
||
nurses_pool = User.objects.filter(is_active=True)
|
||
patients = PatientProfile.objects.filter(tenant=tenant)
|
||
|
||
if not patients.exists():
|
||
raise CommandError("No patients found for the selected tenant.")
|
||
|
||
created_stats = defaultdict(int)
|
||
|
||
@transaction.atomic
|
||
def do_work():
|
||
# Ensure rooms
|
||
rooms = list(OperatingRoom.objects.filter(tenant=tenant))
|
||
if not rooms:
|
||
rooms = self._create_operating_rooms(tenant, rooms_target)
|
||
created_stats["OperatingRoom"] += len(rooms)
|
||
|
||
# Ensure some templates
|
||
created_templates = self._ensure_templates(tenant)
|
||
created_stats["SurgicalNoteTemplate"] += created_templates
|
||
|
||
# For each day, create a mix of elective and emergency blocks and cases
|
||
for i in range(days):
|
||
the_day = start_date + timedelta(days=i)
|
||
# Shuffle rooms daily so distribution varies
|
||
random.shuffle(rooms)
|
||
|
||
# Elective blocks
|
||
created_stats["ORBlock"] += self._create_day_blocks(
|
||
tenant=tenant,
|
||
the_day=the_day,
|
||
rooms=rooms,
|
||
count=elective_blocks,
|
||
block_type="SCHEDULED",
|
||
surgeons=surgeons,
|
||
anesth_pool=anesth_pool,
|
||
time_window=(ELECTIVE_START, ELECTIVE_END),
|
||
)
|
||
|
||
# Emergency blocks
|
||
created_stats["ORBlock"] += self._create_day_blocks(
|
||
tenant=tenant,
|
||
the_day=the_day,
|
||
rooms=rooms,
|
||
count=emergency_blocks,
|
||
block_type="EMERGENCY",
|
||
surgeons=surgeons,
|
||
anesth_pool=anesth_pool,
|
||
time_window=(EMERGENCY_START, EMERGENCY_END),
|
||
)
|
||
|
||
# Fill cases into blocks
|
||
blocks = ORBlock.objects.filter(operating_room__tenant=tenant, date=the_day)
|
||
for block in blocks:
|
||
per_block = random.randint(min_cases, max_cases)
|
||
created, created_eu, created_notes = self._populate_cases_for_block(
|
||
tenant=tenant,
|
||
block=block,
|
||
count=per_block,
|
||
surgeons=surgeons,
|
||
anesth_pool=anesth_pool,
|
||
nurses_pool=nurses_pool,
|
||
patients=patients,
|
||
)
|
||
created_stats["SurgicalCase"] += created
|
||
created_stats["EquipmentUsage"] += created_eu
|
||
created_stats["SurgicalNote"] += created_notes
|
||
|
||
if options["dry_run"]:
|
||
self.stdout.write(self.style.WARNING("DRY RUN: No changes will be committed."))
|
||
with transaction.atomic():
|
||
do_work()
|
||
raise transaction.TransactionManagementError("DRY RUN rollback")
|
||
else:
|
||
do_work()
|
||
|
||
# Summary
|
||
self.stdout.write(self.style.SUCCESS("Data generation completed."))
|
||
for k, v in sorted(created_stats.items()):
|
||
self.stdout.write(f" - {k}: {v}")
|
||
|
||
# ---------- Internal methods ----------
|
||
|
||
def _get_tenant(self, tenant_id):
|
||
if tenant_id:
|
||
try:
|
||
# Try by UUID or PK
|
||
try:
|
||
return Tenant.objects.get(tenant_id=tenant_id)
|
||
except Exception:
|
||
return Tenant.objects.get(pk=tenant_id)
|
||
except Tenant.DoesNotExist:
|
||
raise CommandError("Tenant not found. Provide a valid --tenant-id (UUID or PK).")
|
||
# If only one tenant, pick it, else require explicit
|
||
count = Tenant.objects.count()
|
||
if count == 1:
|
||
return Tenant.objects.first()
|
||
raise CommandError("Multiple tenants found. Please provide --tenant-id.")
|
||
|
||
def _next_case_number(self, tenant, day):
|
||
"""
|
||
Return a unique case number like SURG-YYYYMMDD-0001 scoped to tenant+day.
|
||
Uses DB ordering to find the current max suffix under the prefix.
|
||
"""
|
||
prefix = f"SURG-{day.strftime('%Y%m%d')}-"
|
||
|
||
# Find the lexicographically max case_number under this prefix for this tenant
|
||
last_cn = (
|
||
SurgicalCase.objects
|
||
.filter(or_block__operating_room__tenant=tenant, case_number__startswith=prefix)
|
||
.order_by('-case_number')
|
||
.values_list('case_number', flat=True)
|
||
.first()
|
||
)
|
||
|
||
if not last_cn:
|
||
return f"{prefix}0001"
|
||
|
||
try:
|
||
last_num = int(last_cn.split('-')[-1])
|
||
except Exception:
|
||
# Fallback if an unexpected format is found
|
||
last_num = 0
|
||
return f"{prefix}{last_num + 1:04d}"
|
||
|
||
def _create_operating_rooms(self, tenant, rooms_target):
|
||
rooms = []
|
||
room_types = [choice[0] for choice in OperatingRoom.ROOM_TYPE_CHOICES]
|
||
for idx in range(1, rooms_target + 1):
|
||
rt = random.choice(room_types)
|
||
building = random.choice(SAUDI_BUILDINGS)
|
||
wing = random.choice(SAUDI_WINGS)
|
||
|
||
has_c_arm = rt in {"ORTHOPEDIC", "TRAUMA", "VASCULAR", "THORACIC", "UROLOGY"}
|
||
supports_robotic = rt in {"UROLOGY", "GENERAL", "GYNE", "ORTHOPEDIC", "CARDIAC", "TRANSPLANT", "THORACIC", "NEURO", "PLASTIC"}
|
||
has_ultrasound = rt in {"GENERAL", "UROLOGY", "OBSTETRIC", "VASCULAR"}
|
||
|
||
equipment_list = [e[0] for e in random_equipment_set(rt, supports_robotic, has_c_arm, has_ultrasound)]
|
||
features = random.sample(ROOM_FEATURES, k=min(len(ROOM_FEATURES), random.randint(3, 6)))
|
||
|
||
room = OperatingRoom(
|
||
tenant=tenant,
|
||
room_number=f"OR-{idx:02d}",
|
||
room_name=f"Operating Room {idx}",
|
||
room_type=rt,
|
||
status="AVAILABLE",
|
||
floor_number=random.randint(1, 4),
|
||
room_size=round(random.uniform(35.0, 70.0), 1),
|
||
ceiling_height=round(random.uniform(2.8, 3.5), 1),
|
||
temperature_min=18.0,
|
||
temperature_max=26.0,
|
||
humidity_min=30.0,
|
||
humidity_max=60.0,
|
||
air_changes_per_hour=random.choice([20, 25, 30]),
|
||
positive_pressure=True,
|
||
equipment_list=equipment_list,
|
||
special_features=features,
|
||
has_c_arm=has_c_arm,
|
||
has_ct=False,
|
||
has_mri=False,
|
||
has_ultrasound=has_ultrasound,
|
||
has_neuromonitoring=rt in {"NEURO", "ORTHOPEDIC", "SPINE"},
|
||
supports_robotic=supports_robotic,
|
||
supports_laparoscopic=True,
|
||
supports_microscopy=rt in {"NEURO", "PLASTIC", "ENT"},
|
||
supports_laser=rt in {"UROLOGY", "ENT", "OPHTHALMOLOGY"},
|
||
max_case_duration=random.choice([480, 540, 600]),
|
||
turnover_time=random.choice([25, 30, 35]),
|
||
cleaning_time=random.choice([40, 45, 50]),
|
||
required_nurses=random.choice([2, 3, 4]),
|
||
required_techs=random.choice([1, 2]),
|
||
is_active=True,
|
||
accepts_emergency=True,
|
||
building=building,
|
||
wing=wing,
|
||
)
|
||
rooms.append(room)
|
||
OperatingRoom.objects.bulk_create(rooms)
|
||
return list(OperatingRoom.objects.filter(tenant=tenant))
|
||
|
||
def _ensure_templates(self, tenant):
|
||
created = 0
|
||
for specialty_code, specialty_label in SurgicalNoteTemplate.SPECIALTY_CHOICES:
|
||
name = f"Default {specialty_label} Template"
|
||
if not SurgicalNoteTemplate.objects.filter(tenant=tenant, name=name).exists():
|
||
SurgicalNoteTemplate.objects.create(
|
||
tenant=tenant,
|
||
name=name,
|
||
description=f"Standardized note template for {specialty_label}.",
|
||
procedure_type=None,
|
||
specialty=specialty_code,
|
||
preoperative_diagnosis_template="Preoperative diagnosis: {{ diagnosis }}.",
|
||
planned_procedure_template="Planned procedure: {{ primary_procedure }}.",
|
||
indication_template="Indications include symptom severity and failed conservative management.",
|
||
procedure_performed_template="Procedure performed as planned with appropriate variations.",
|
||
surgical_approach_template="Approach: {{ approach }} with standard sterile technique.",
|
||
findings_template="Key intraoperative findings noted.",
|
||
technique_template="Step-wise technique documented with hemostasis achieved.",
|
||
postoperative_diagnosis_template="Postoperative diagnosis similar to preoperative with noted changes.",
|
||
complications_template="No complications unless specified.",
|
||
specimens_template="Specimens sent to pathology as listed.",
|
||
implants_template="Implants, if any, listed with lot numbers.",
|
||
closure_template="Layered closure with appropriate sutures.",
|
||
postop_instructions_template="Routine postoperative instructions and follow-up plan.",
|
||
is_active=True,
|
||
is_default=(specialty_code == "ALL"),
|
||
usage_count=0,
|
||
created_by=None,
|
||
)
|
||
created += 1
|
||
return created
|
||
|
||
def _create_day_blocks(self, tenant, the_day, rooms, count, block_type, surgeons, anesth_pool, time_window):
|
||
if count <= 0 or not rooms:
|
||
return 0
|
||
|
||
created = 0
|
||
start_t, end_t = time_window
|
||
window_minutes = minutes_between(start_t, end_t)
|
||
|
||
# Each block ~ 90–180 minutes
|
||
blocks_to_make = count
|
||
room_idx = 0
|
||
|
||
while blocks_to_make > 0 and room_idx < len(rooms):
|
||
room = rooms[room_idx]
|
||
room_idx += 1
|
||
|
||
# Decide number of blocks for this room for the day
|
||
per_room = random.randint(1, min(3, blocks_to_make))
|
||
# Build non-overlapping blocks
|
||
cursor_minute = 0
|
||
for _ in range(per_room):
|
||
if cursor_minute + 90 > window_minutes:
|
||
break
|
||
dur = random.choice([90, 120, 150, 180])
|
||
if cursor_minute + dur > window_minutes:
|
||
dur = window_minutes - cursor_minute
|
||
if dur < 60:
|
||
break
|
||
|
||
block_start = (datetime.combine(the_day, start_t) + timedelta(minutes=cursor_minute)).time()
|
||
block_end_dt = (datetime.combine(the_day, start_t) + timedelta(minutes=cursor_minute + dur))
|
||
block_end = block_end_dt.time()
|
||
|
||
# Assign primary surgeon
|
||
primary = choose_staff(surgeons, k=1)
|
||
if not primary:
|
||
continue
|
||
primary = primary[0]
|
||
|
||
service = random.choice(list(SERVICE_TO_PROCEDURES.keys()))
|
||
ob = ORBlock(
|
||
operating_room=room,
|
||
date=the_day,
|
||
start_time=block_start,
|
||
end_time=block_end,
|
||
block_type=block_type,
|
||
primary_surgeon=primary,
|
||
service=service,
|
||
status="SCHEDULED",
|
||
allocated_minutes=0, # computed in save()
|
||
used_minutes=0,
|
||
special_equipment=[],
|
||
special_setup=None,
|
||
notes=f"{block_type.title()} block for {service} service.",
|
||
created_by=None,
|
||
)
|
||
ob.save()
|
||
created += 1
|
||
|
||
# Occasionally add assistant surgeons
|
||
assistants = choose_staff(surgeons, k=random.choice([0, 1, 2]), exclude=[primary])
|
||
if assistants:
|
||
ob.assistant_surgeons.add(*assistants)
|
||
|
||
# Add special equipment hints
|
||
if room.supports_robotic and random.random() < 0.2:
|
||
ob.special_equipment.append("Robot Instruments Set")
|
||
if room.has_c_arm and random.random() < 0.3:
|
||
ob.special_equipment.append("C-Arm Ready")
|
||
if room.has_ultrasound and random.random() < 0.3:
|
||
ob.special_equipment.append("Intraop Ultrasound Probe")
|
||
|
||
ob.save()
|
||
|
||
cursor_minute += dur + room.turnover_time
|
||
|
||
if created >= count:
|
||
break
|
||
if created >= count:
|
||
break
|
||
|
||
return created
|
||
|
||
def _populate_cases_for_block(self, tenant, block, count, surgeons, anesth_pool, nurses_pool, patients):
|
||
created_cases = 0
|
||
created_usage = 0
|
||
created_notes = 0
|
||
|
||
# Time slicing within the block for each case
|
||
block_start_dt = dt_in_ksa(block.date, block.start_time)
|
||
block_end_dt = dt_in_ksa(block.date, block.end_time)
|
||
available_minutes = int((block_end_dt - block_start_dt).total_seconds() // 60)
|
||
|
||
# Estimate per-case minutes
|
||
per_case_estimates = []
|
||
remaining = available_minutes
|
||
for i in range(count):
|
||
est = random.choice([60, 90, 120, 150])
|
||
if i == count - 1:
|
||
est = max(45, remaining)
|
||
per_case_estimates.append(est)
|
||
remaining -= est
|
||
if remaining < 45 and i < count - 1:
|
||
# Reduce case count if not enough room
|
||
count = i + 1
|
||
per_case_estimates = per_case_estimates[:count]
|
||
break
|
||
|
||
current_start = block_start_dt
|
||
|
||
for i in range(count):
|
||
est_minutes = per_case_estimates[i]
|
||
sched_start = current_start
|
||
# Keep end within block
|
||
est_end = sched_start + timedelta(minutes=est_minutes)
|
||
if est_end > block_end_dt:
|
||
est_end = block_end_dt
|
||
|
||
# Staff
|
||
primary_surgeon = block.primary_surgeon
|
||
assistants = choose_staff(surgeons, k=random.choice([0, 1, 2]), exclude=[primary_surgeon])
|
||
anesthesiologist = choose_staff(anesth_pool, k=1, exclude=[primary_surgeon] + assistants)
|
||
anesthesiologist = anesthesiologist[0] if anesthesiologist else None
|
||
circ_nurse = choose_staff(nurses_pool, k=1, exclude=[primary_surgeon] + assistants + ([anesthesiologist] if anesthesiologist else []))
|
||
circ_nurse = circ_nurse[0] if circ_nurse else None
|
||
scrub_nurse = choose_staff(nurses_pool, k=1, exclude=[primary_surgeon] + assistants + ([anesthesiologist] if anesthesiologist else []) + ([circ_nurse] if circ_nurse else []))
|
||
scrub_nurse = scrub_nurse[0] if scrub_nurse else None
|
||
|
||
# Procedure selection by service
|
||
service = block.service
|
||
proc_triplet = random.choice(SERVICE_TO_PROCEDURES[service])
|
||
primary_proc, secondary_proc_list, cpts = proc_triplet
|
||
|
||
case_type = probable_case_type(service)
|
||
approach = pick_approach(service)
|
||
anesthesia = pick_anesthesia()
|
||
position = pick_position(service)
|
||
diagnosis, icd10s = random.choice(DIAGNOSES)
|
||
|
||
patient = patients.order_by('?').first()
|
||
encounter = safe_get_encounter(patient)
|
||
admission = safe_get_admission(patient) if case_type != "ELECTIVE" and random.random() < 0.5 else None
|
||
|
||
day = sched_start.date()
|
||
for attempt in range(5):
|
||
try:
|
||
case_number = self._next_case_number(tenant, day)
|
||
|
||
sc = SurgicalCase(
|
||
or_block=block,
|
||
case_number=case_number, # <-- set explicitly (non-empty)
|
||
patient=patient,
|
||
primary_surgeon=primary_surgeon,
|
||
# assistants added after save
|
||
anesthesiologist=anesthesiologist,
|
||
circulating_nurse=circ_nurse,
|
||
scrub_nurse=scrub_nurse,
|
||
primary_procedure=primary_proc,
|
||
secondary_procedures=secondary_proc_list[:],
|
||
procedure_codes=cpts[:],
|
||
case_type=case_type,
|
||
approach=approach,
|
||
anesthesia_type=anesthesia,
|
||
scheduled_start=sched_start,
|
||
estimated_duration=est_minutes,
|
||
actual_start=None,
|
||
actual_end=None,
|
||
status="SCHEDULED",
|
||
diagnosis=diagnosis,
|
||
diagnosis_codes=icd10s[:],
|
||
clinical_notes="Patient optimized preoperatively. Informed consent obtained.",
|
||
special_equipment=[],
|
||
blood_products=random.sample(BLOOD_PRODUCTS, k=random.choice([0, 1, 2])),
|
||
implants=random.sample(IMPLANT_EXAMPLES, k=random.choice([0, 1, 2])),
|
||
patient_position=position,
|
||
complications=[],
|
||
estimated_blood_loss=None,
|
||
encounter=encounter,
|
||
admission=admission,
|
||
created_by=None,
|
||
)
|
||
sc.save()
|
||
break # success
|
||
except IntegrityError:
|
||
# Someone else grabbed that number; try again with the next one
|
||
if attempt == 4:
|
||
raise
|
||
continue
|
||
# Optional: set realistic actual times (simulate some completed cases)
|
||
if random.random() < 0.4:
|
||
delay = random.choice([0, 5, 10, 15])
|
||
overrun = random.choice([-10, 0, 10, 20, 30])
|
||
sc.actual_start = sc.scheduled_start + timedelta(minutes=delay)
|
||
sc.actual_end = sc.actual_start + timedelta(minutes=max(45, est_minutes + overrun))
|
||
sc.status = "COMPLETED"
|
||
sc.estimated_blood_loss = random.choice([100, 150, 200, 300, 500])
|
||
sc.save()
|
||
|
||
created_cases += 1
|
||
|
||
# Equipment usage entries (a few per case)
|
||
eu_set = random_equipment_set(
|
||
service=service,
|
||
robotic=block.operating_room.supports_robotic and approach == "ROBOTIC",
|
||
has_c_arm=block.operating_room.has_c_arm,
|
||
has_ultrasound=block.operating_room.has_ultrasound,
|
||
)
|
||
for (eq_name, eq_type, manu, model) in random.sample(eu_set, k=min(len(eu_set), random.randint(2, 5))):
|
||
started = sc.scheduled_start + timedelta(minutes=random.choice([0, 5, 10, 15]))
|
||
ended = started + timedelta(minutes=random.choice([20, 30, 45, 60]))
|
||
eu = EquipmentUsage(
|
||
surgical_case=sc,
|
||
equipment_name=eq_name,
|
||
equipment_type=eq_type,
|
||
manufacturer=manu,
|
||
model=model,
|
||
quantity_used=random.choice([1, 1, 2]),
|
||
unit_of_measure="EACH",
|
||
start_time=started if random.random() < 0.8 else None,
|
||
end_time=ended if random.random() < 0.7 else None,
|
||
unit_cost=random.choice([None, 250.00, 500.00, 1200.00]),
|
||
total_cost=None,
|
||
lot_number=str(uuid.uuid4())[:8] if random.random() < 0.4 else None,
|
||
expiration_date=None,
|
||
sterilization_date=block.date - timedelta(days=random.randint(1, 14)) if random.random() < 0.5 else None,
|
||
notes=None,
|
||
recorded_by=primary_surgeon if random.random() < 0.3 else None,
|
||
)
|
||
eu.save()
|
||
created_usage += 1
|
||
|
||
# Surgical Note (draft or completed)
|
||
note_status = random.choice(["DRAFT", "COMPLETED", "SIGNED"])
|
||
tmpl = SurgicalNoteTemplate.objects.filter(tenant=tenant, is_active=True).order_by('?').first()
|
||
sn = SurgicalNote(
|
||
surgical_case=sc,
|
||
surgeon=primary_surgeon,
|
||
preoperative_diagnosis=f"{diagnosis}.",
|
||
planned_procedure=primary_proc,
|
||
indication="Worsening symptoms with failure of conservative management.",
|
||
procedure_performed=f"{primary_proc} performed; secondary procedures as indicated.",
|
||
surgical_approach=f"Approach: {approach}. Standard sterile preparation and draping.",
|
||
findings="Findings consistent with preoperative diagnosis.",
|
||
technique="Procedure completed in steps with meticulous hemostasis.",
|
||
postoperative_diagnosis=diagnosis,
|
||
condition=random.choice([c[0] for c in SurgicalNote.CONDITION_CHOICES]),
|
||
disposition=random.choice([d[0] for d in SurgicalNote.DISPOSITION_CHOICES]),
|
||
complications=None if sc.status == "COMPLETED" and random.random() < 0.7 else "Minor bleeding controlled with cautery.",
|
||
estimated_blood_loss=sc.estimated_blood_loss if sc.estimated_blood_loss else random.choice([None, 150, 200]),
|
||
blood_transfusion=None if random.random() < 0.7 else "1 unit PRBC transfused intraoperatively.",
|
||
specimens="Specimen sent to pathology as required." if random.random() < 0.5 else None,
|
||
implants=", ".join(sc.implants) if sc.implants else None,
|
||
drains="Closed-suction drain placed to dependent region." if random.random() < 0.3 else None,
|
||
closure="Layered closure with absorbable sutures; skin staples as needed.",
|
||
postop_instructions="Early ambulation, incentive spirometry, pain control, DVT prophylaxis.",
|
||
follow_up="Outpatient clinic in 1–2 weeks; sooner if concerns.",
|
||
status=note_status,
|
||
signed_datetime=(
|
||
timezone.now() if note_status == "SIGNED" else None
|
||
),
|
||
template_used=tmpl,
|
||
)
|
||
sn.save()
|
||
created_notes += 1
|
||
|
||
# Move pointer for next case
|
||
current_start = est_end + timedelta(minutes=block.operating_room.turnover_time)
|
||
|
||
# Update block used_minutes based on completed cases
|
||
used = 0
|
||
for case in block.surgical_cases.all():
|
||
if case.actual_start and case.actual_end:
|
||
used += int((case.actual_end - case.actual_start).total_seconds() // 60)
|
||
else:
|
||
used += case.estimated_duration
|
||
block.used_minutes = min(used, block.allocated_minutes or used)
|
||
block.status = "ACTIVE" if block.used_minutes > 0 else block.status
|
||
block.save()
|
||
|
||
return created_cases, created_usage, created_notes |