Marwan Alwali 610e165e17 update
2025-09-04 19:19:52 +03:00

802 lines
35 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 dont 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 ~ 90180 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 12 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