518 lines
23 KiB
Python
518 lines
23 KiB
Python
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})"
|
|
|