from django.db import models from django.core.validators import MinValueValidator, MaxValueValidator from django.utils import timezone from datetime import timedelta from hr.models import Department from patients.models import PatientProfile from django.conf import settings class BloodGroup(models.Model): """Blood group types (A, B, AB, O) with Rh factor""" class ABOGroup(models.TextChoices): A = 'A', 'A' B = 'B', 'B' AB = 'AB', 'AB' O = 'O', 'O' class RhType(models.TextChoices): POSITIVE = 'POS', 'Positive' NEGATIVE = 'NEG', 'Negative' abo_type = models.CharField(max_length=2, choices=ABOGroup.choices) rh_factor = models.CharField(max_length=8, choices=RhType.choices) class Meta: unique_together = ['abo_type', 'rh_factor'] ordering = ['abo_type', 'rh_factor'] def __str__(self): return f"{self.abo_type} {self.rh_factor.capitalize()}" @property def display_name(self): rh_symbol = '+' if self.rh_factor == 'POS' else '-' return f"{self.abo_type}{rh_symbol}" class Donor(models.Model): """Blood donor information and eligibility""" class DonorType(models.TextChoices): VOLUNTARY = 'VOLUNTARY', 'Voluntary' REPLACEMENT = 'REPLACEMENT', 'Replacement' AUTOLOGOUS = 'AUTOLOGOUS', 'Autologous' DIRECTED = 'DIRECTED', 'Directed' class DonorStatus(models.TextChoices): ACTIVE = 'ACTIVE', 'Active' DEFERRED = 'DEFERRED', 'Deferred' PERMANENTLY_DEFERRED = 'PERMANENTLY_DEFERRED', 'Permanently Deferred' INACTIVE = 'INACTIVE', 'Inactive' donor_id = models.CharField(max_length=20, unique=True) first_name = models.CharField(max_length=100) last_name = models.CharField(max_length=100) date_of_birth = models.DateField() gender = models.CharField(max_length=10, choices=[('MALE', 'Male'), ('FEMALE', 'Female'), ('OTHER', 'Other')]) national_id = models.CharField(max_length=10, unique=True) blood_group = models.ForeignKey(BloodGroup, on_delete=models.PROTECT) phone = models.CharField(max_length=20) email = models.EmailField(blank=True) address = models.TextField() emergency_contact_name = models.CharField(max_length=100) emergency_contact_phone = models.CharField(max_length=20) donor_type = models.CharField(max_length=20, choices=DonorType.choices, default=DonorType.VOLUNTARY) status = models.CharField(max_length=20, choices=DonorStatus.choices, default=DonorStatus.ACTIVE) registration_date = models.DateTimeField(auto_now_add=True) last_donation_date = models.DateTimeField(null=True, blank=True) total_donations = models.PositiveIntegerField(default=0) weight = models.FloatField(validators=[MinValueValidator(45.0)]) # Minimum weight for donation height = models.FloatField(validators=[MinValueValidator(140.0)]) # In cm notes = models.TextField(blank=True) created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='created_donors') created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) class Meta: ordering = ['-registration_date'] def __str__(self): return f"{self.donor_id} - {self.first_name} {self.last_name}" @property def full_name(self): return f"{self.first_name} {self.last_name}" @property def age(self): today = timezone.now().date() return today.year - self.date_of_birth.year - ( (today.month, today.day) < (self.date_of_birth.month, self.date_of_birth.day)) @property def is_eligible_for_donation(self): """Check if donor is eligible for donation based on last donation date""" if self.status != 'active': return False if not self.last_donation_date: return True # Minimum 56 days between whole blood donations days_since_last = (timezone.now() - self.last_donation_date).days return days_since_last >= 56 @property def next_eligible_date(self): """Calculate next eligible donation date""" if not self.last_donation_date: return timezone.now().date() return (self.last_donation_date + timedelta(days=56)).date() class BloodComponent(models.Model): """Types of blood components (Whole Blood, RBC, Plasma, Platelets, etc.)""" class BloodComponent(models.TextChoices): WHOLE_BLOOD = 'WHOLE_BLOOD', 'Whole Blood' PACKED_RBC = 'PACKED_RBC', 'Packed Red Blood Cells' FRESH_FROZEN_PLASMA = 'FRESH_FROZEN_PLASMA', 'Fresh Frozen Plasma' PLATELETS = 'PLATELETS', 'Platelets' CRYOPRECIPITATE = 'CRYOPRECIPITATE', 'Cryoprecipitate' GRANULOCYTES = 'GRANULOCYTES', 'Granulocytes' name = models.CharField(max_length=50, choices=BloodComponent.choices, unique=True) description = models.TextField() shelf_life_days = models.PositiveIntegerField() # Storage duration in days storage_temperature = models.CharField(max_length=50) # Storage requirements volume_ml = models.PositiveIntegerField() # Standard volume in ml is_active = models.BooleanField(default=True) class Meta: ordering = ['name'] def __str__(self): return self.get_name_display() class BloodUnit(models.Model): """Individual blood unit from donation to disposal""" class BloodUnitStatus(models.TextChoices): COLLECTED = 'COLLECTED', 'Collected' TESTING = 'TESTING', 'Testing' QUARANTINE = 'QUARANTINE', 'Quarantine' AVAILABLE = 'AVAILABLE', 'Available' RESERVED = 'RESERVED', 'Reserved' ISSUED = 'ISSUED', 'Issued' TRANSFUSED = 'TRANSFUSED', 'Transfused' EXPIRED = 'EXPIRED', 'Expired' DISCARDED = 'DISCARDED', 'Discarded' unit_number = models.CharField(max_length=20, unique=True) donor = models.ForeignKey(Donor, on_delete=models.PROTECT, related_name='blood_units') component = models.ForeignKey(BloodComponent, on_delete=models.PROTECT) blood_group = models.ForeignKey(BloodGroup, on_delete=models.PROTECT) collection_date = models.DateTimeField() expiry_date = models.DateTimeField() volume_ml = models.PositiveIntegerField() status = models.CharField(max_length=20, choices=BloodUnitStatus.choices, default=BloodUnitStatus.COLLECTED) location = models.CharField(max_length=100) # Storage location/refrigerator bag_type = models.CharField(max_length=50) anticoagulant = models.CharField(max_length=50, default='CPDA-1') collection_site = models.CharField(max_length=100) collected_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='collected_units') notes = models.TextField(blank=True) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) class Meta: ordering = ['-collection_date'] def __str__(self): return f"{self.unit_number} - {self.component} ({self.blood_group})" @property def is_expired(self): return timezone.now() > self.expiry_date @property def days_to_expiry(self): delta = self.expiry_date - timezone.now() return delta.days if delta.days > 0 else 0 @property def is_available(self): return self.status == 'available' and not self.is_expired class BloodTest(models.Model): """Blood testing results for infectious diseases and compatibility""" class TestType(models.TextChoices): ABO_RH = 'ABO_RH', 'ABO/Rh Typing' ANTIBODY_SCREEN = 'ANTIBODY_SCREEN', 'Antibody Screening' HIV = 'HIV', 'HIV' HBV = 'HBV', 'Hepatitis B' HCV = 'HCV', 'Hepatitis C' SYPHILIS = 'SYPHILIS', 'Syphilis' HTLV = 'HTLV', 'HTLV' CMV = 'CMV', 'CMV' MALARIA = 'MALARIA', 'Malaria' class TestResult(models.TextChoices): POSITIVE = 'POSITIVE', 'Positive' NEGATIVE = 'NEGATIVE', 'Negative' INDETERMINATE = 'INDETERMINATE', 'Indeterminate' PENDING = 'PENDING', 'Pending' blood_unit = models.ForeignKey(BloodUnit, on_delete=models.CASCADE, related_name='tests') test_type = models.CharField(max_length=20, choices=TestType.choices) result = models.CharField(max_length=15, choices=TestResult.choices, default=TestResult.PENDING) test_date = models.DateTimeField() tested_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT) equipment_used = models.CharField(max_length=100, blank=True) lot_number = models.CharField(max_length=50, blank=True) notes = models.TextField(blank=True) verified_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='verified_tests', null=True, blank=True) verified_at = models.DateTimeField(null=True, blank=True) class Meta: unique_together = ['blood_unit', 'test_type'] ordering = ['-test_date'] def __str__(self): return f"{self.blood_unit.unit_number} - {self.get_test_type_display()}: {self.result}" class CrossMatch(models.Model): """Cross-matching tests between donor blood and recipient""" class CompatibilityStatus(models.TextChoices): COMPATIBLE = 'COMPATIBLE', 'Compatible' INCOMPATIBLE = 'INCOMPATIBLE', 'Incompatible' PENDING = 'PENDING', 'Pending' class CrossmatchTestType(models.TextChoices): MAJOR = 'MAJOR', 'Major Crossmatch' MINOR = 'MINOR', 'Minor Crossmatch' IMMEDIATE_SPIN = 'IMMEDIATE_SPIN', 'Immediate Spin' ANTIGLOBULIN = 'ANTIGLOBULIN', 'Antiglobulin Test' blood_unit = models.ForeignKey(BloodUnit, on_delete=models.CASCADE, related_name='crossmatches') recipient = models.ForeignKey(PatientProfile, on_delete=models.PROTECT) test_type = models.CharField(max_length=20, choices=CrossmatchTestType.choices) compatibility = models.CharField(max_length=15, choices=CompatibilityStatus.choices, default=CompatibilityStatus.PENDING) test_date = models.DateTimeField() tested_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT) temperature = models.CharField(max_length=20, default='37°C') incubation_time = models.PositiveIntegerField(default=15) # minutes notes = models.TextField(blank=True) verified_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='verified_crossmatches', null=True, blank=True) verified_at = models.DateTimeField(null=True, blank=True) class Meta: ordering = ['-test_date'] def __str__(self): return f"{self.blood_unit.unit_number} x {self.recipient} - {self.compatibility}" class BloodRequest(models.Model): """Blood transfusion requests from clinical departments""" class UrgencyLevel(models.TextChoices): ROUTINE = 'ROUTINE', 'Routine' URGENT = 'URGENT', 'Urgent' EMERGENCY = 'EMERGENCY', 'Emergency' class RequestStatus(models.TextChoices): PENDING = 'PENDING', 'Pending' PROCESSING = 'PROCESSING', 'Processing' READY = 'READY', 'Ready' ISSUED = 'ISSUED', 'Issued' COMPLETED = 'COMPLETED', 'Completed' CANCELLED = 'CANCELLED', 'Cancelled' request_number = models.CharField(max_length=20, unique=True) patient = models.ForeignKey(PatientProfile, on_delete=models.PROTECT, related_name='blood_requests') requesting_department = models.ForeignKey(Department, on_delete=models.PROTECT) requesting_physician = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='blood_requests') component_requested = models.ForeignKey(BloodComponent, on_delete=models.PROTECT) units_requested = models.PositiveIntegerField(validators=[MinValueValidator(1)]) urgency = models.CharField(max_length=10, choices=UrgencyLevel.choices, default=UrgencyLevel.ROUTINE) indication = models.TextField() # Clinical indication for transfusion special_requirements = models.TextField(blank=True) # CMV negative, irradiated, etc. patient_blood_group = models.ForeignKey(BloodGroup, on_delete=models.PROTECT) hemoglobin_level = models.FloatField(null=True, blank=True) platelet_count = models.IntegerField(null=True, blank=True) status = models.CharField(max_length=15, choices=RequestStatus.choices, default=RequestStatus.PENDING) request_date = models.DateTimeField(auto_now_add=True) required_by = models.DateTimeField() processed_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='processed_requests', null=True, blank=True) processed_at = models.DateTimeField(null=True, blank=True) notes = models.TextField(blank=True) # Cancellation fields cancellation_reason = models.TextField(blank=True) cancelled_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='cancelled_requests', null=True, blank=True) cancellation_date = models.DateTimeField(null=True, blank=True) class Meta: ordering = ['-request_date'] def __str__(self): return f"{self.request_number} - {self.patient} ({self.component_requested})" @property def is_overdue(self): return timezone.now() > self.required_by and self.status not in ['completed', 'cancelled'] class BloodIssue(models.Model): """Blood unit issuance to patients""" blood_request = models.ForeignKey(BloodRequest, on_delete=models.PROTECT, related_name='issues') blood_unit = models.OneToOneField(BloodUnit, on_delete=models.PROTECT, related_name='issue') crossmatch = models.ForeignKey(CrossMatch, on_delete=models.PROTECT, null=True, blank=True) issued_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='issued_units') issued_to = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='received_units') # Nurse/physician issue_date = models.DateTimeField(auto_now_add=True) expiry_time = models.DateTimeField() # 4 hours from issue for RBC returned = models.BooleanField(default=False) return_date = models.DateTimeField(null=True, blank=True) return_reason = models.TextField(blank=True) notes = models.TextField(blank=True) class Meta: ordering = ['-issue_date'] def __str__(self): return f"{self.blood_unit.unit_number} issued to {self.blood_request.patient}" @property def is_expired(self): return timezone.now() > self.expiry_time and not self.returned class Transfusion(models.Model): """Blood transfusion administration records""" class TransfusionStatus(models.TextChoices): STARTED = 'STARTED', 'Started' IN_PROGRESS = 'IN_PROGRESS', 'In Progress' COMPLETED = 'COMPLETED', 'Completed' STOPPED = 'STOPPED', 'Stopped' ADVERSE_REACTION = 'ADVERSE_REACTION', 'Adverse Reaction' blood_issue = models.OneToOneField(BloodIssue, on_delete=models.PROTECT, related_name='transfusion') start_time = models.DateTimeField() end_time = models.DateTimeField(null=True, blank=True) status = models.CharField(max_length=20, choices=TransfusionStatus.choices, default=TransfusionStatus.STARTED) volume_transfused = models.PositiveIntegerField(null=True, blank=True) # ml transfusion_rate = models.CharField(max_length=50, blank=True) # ml/hour administered_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='administered_transfusions') witnessed_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='witnessed_transfusions', null=True, blank=True) pre_transfusion_vitals = models.JSONField(default=dict) # BP, HR, Temp, etc. post_transfusion_vitals = models.JSONField(default=dict) vital_signs_history = models.JSONField(default=list) # Array of vital signs during transfusion current_blood_pressure = models.CharField(max_length=20, blank=True) current_heart_rate = models.IntegerField(null=True, blank=True) current_temperature = models.FloatField(null=True, blank=True) current_respiratory_rate = models.IntegerField(null=True, blank=True) current_oxygen_saturation = models.IntegerField(null=True, blank=True) last_vitals_check = models.DateTimeField(null=True, blank=True) patient_consent = models.BooleanField(default=False) consent_date = models.DateTimeField(null=True, blank=True) notes = models.TextField(blank=True) # Completion/Stop fields stop_reason = models.TextField(blank=True) stopped_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='stopped_transfusions', null=True, blank=True) completed_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='completed_transfusions', null=True, blank=True) completion_notes = models.TextField(blank=True) class Meta: ordering = ['-start_time'] def __str__(self): return f"Transfusion: {self.blood_issue.blood_unit.unit_number} to {self.blood_issue.blood_request.patient}" @property def duration_minutes(self): if self.end_time: return int((self.end_time - self.start_time).total_seconds() / 60) return None class AdverseReaction(models.Model): """Adverse transfusion reactions""" class ReactionSeverity(models.TextChoices): MILD = 'MILD', 'Mild' MODERATE = 'MODERATE', 'Moderate' SEVERE = 'SEVERE', 'Severe' LIFE_THREATENING = 'LIFE_THREATENING', 'Life Threatening' class ReactionType(models.TextChoices): FEBRILE = 'FEBRILE', 'Febrile Non-Hemolytic' ALLERGIC = 'ALLERGIC', 'Allergic' HEMOLYTIC_ACUTE = 'HEMOLYTIC_ACUTE', 'Acute Hemolytic' HEMOLYTIC_DELAYED = 'HEMOLYTIC_DELAYED', 'Delayed Hemolytic' ANAPHYLACTIC = 'ANAPHYLACTIC', 'Anaphylactic' SEPTIC = 'SEPTIC', 'Septic' CIRCULATORY_OVERLOAD = 'CIRCULATORY_OVERLOAD', 'Circulatory Overload' LUNG_INJURY = 'LUNG_INJURY', 'Transfusion-Related Acute Lung Injury' OTHER = 'OTHER', 'Other' transfusion = models.ForeignKey(Transfusion, on_delete=models.CASCADE, related_name='adverse_reactions') reaction_type = models.CharField(max_length=30, choices=ReactionType.choices) severity = models.CharField(max_length=20, choices=ReactionSeverity.choices) onset_time = models.DateTimeField() symptoms = models.TextField() treatment_given = models.TextField() outcome = models.TextField() reported_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='reported_reactions') investigated_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='investigated_reactions', null=True, blank=True) investigation_notes = models.TextField(blank=True) regulatory_reported = models.BooleanField(default=False) report_date = models.DateTimeField(null=True, blank=True) class Meta: ordering = ['-onset_time'] def __str__(self): return f"{self.get_reaction_type_display()} - {self.severity} ({self.transfusion.blood_issue.blood_request.patient})" class InventoryLocation(models.Model): """Blood bank storage locations""" class LocationType(models.TextChoices): REFRIGERATOR = 'REFRIGERATOR', 'Refrigerator' FREEZER = 'FREEZER', 'Freezer' PLATELET_AGITATOR = 'PLATELET_AGITATOR', 'Platelet Agitator' QUARANTINE = 'QUARANTINE', 'Quarantine' TESTING = 'TESTING', 'Testing Area' name = models.CharField(max_length=100, unique=True) location_type = models.CharField(max_length=20, choices=LocationType.choices) temperature_range = models.CharField(max_length=50) temperature = models.FloatField(null=True, blank=True) # Current temperature capacity = models.PositiveIntegerField() # Number of units current_stock = models.PositiveIntegerField(default=0) is_active = models.BooleanField(default=True) notes = models.TextField(blank=True) class Meta: ordering = ['name'] def __str__(self): return f"{self.name} ({self.get_location_type_display()})" @property def utilization_percentage(self): if self.capacity == 0: return 0 return (self.current_stock / self.capacity) * 100 class QualityControl(models.Model): """Quality control tests and monitoring""" class QualityTestType(models.TextChoices): TEMPERATURE_MONITORING = 'TEMPERATURE_MONITORING', 'Temperature Monitoring' EQUIPMENT_CALIBRATION = 'EQUIPMENT_CALIBRATION', 'Equipment Calibration' REAGENT_TESTING = 'REAGENT_TESTING', 'Reagent Testing' PROFICIENCY_TESTING = 'PROFICIENCY_TESTING', 'Proficiency Testing' PROCESS_VALIDATION = 'PROCESS_VALIDATION', 'Process Validation' class QualityTestStatus(models.TextChoices): PASS = 'PASS', 'Pass' FAIL = 'FAIL', 'Fail' PENDING = 'PENDING', 'Pending' test_type = models.CharField(max_length=30, choices=QualityTestType.choices) test_date = models.DateTimeField() equipment_tested = models.CharField(max_length=100, blank=True) parameters_tested = models.TextField() expected_results = models.TextField() actual_results = models.TextField() status = models.CharField(max_length=10, choices=QualityTestStatus.choices) performed_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='qc_tests') reviewed_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='reviewed_qc_tests', null=True, blank=True) review_date = models.DateTimeField(null=True, blank=True) review_notes = models.TextField(blank=True) corrective_action = models.TextField(blank=True) next_test_date = models.DateTimeField(null=True, blank=True) # CAPA (Corrective and Preventive Action) fields capa_initiated = models.BooleanField(default=False) capa_number = models.CharField(max_length=50, blank=True) capa_priority = models.CharField(max_length=10, choices=[('LOW', 'Low'), ('MEDIUM', 'Medium'), ('HIGH', 'High')], blank=True) capa_initiated_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='initiated_capas', null=True, blank=True) capa_date = models.DateTimeField(null=True, blank=True) capa_assessment = models.TextField(blank=True) capa_status = models.CharField(max_length=20, choices=[('OPEN', 'Open'), ('IN_PROGRESS', 'In Progress'), ('CLOSED', 'Closed')], blank=True) class Meta: ordering = ['-test_date'] def __str__(self): return f"{self.get_test_type_display()} - {self.test_date.strftime('%Y-%m-%d')} ({self.status})"