1361 lines
39 KiB
Python
1361 lines
39 KiB
Python
"""
|
|
Pharmacy app models for hospital management system.
|
|
Provides medication management, prescription processing, and pharmacy operations.
|
|
"""
|
|
|
|
import uuid
|
|
from django.db import models
|
|
from django.core.validators import RegexValidator, MinValueValidator, MaxValueValidator
|
|
from django.utils import timezone
|
|
from django.conf import settings
|
|
from django.contrib.contenttypes.fields import GenericRelation
|
|
from datetime import timedelta, datetime, date
|
|
from decimal import Decimal
|
|
import json
|
|
|
|
|
|
class Medication(models.Model):
|
|
"""
|
|
Medication model for drug database and formulary management.
|
|
"""
|
|
class ControlledSubstanceSchedule(models.TextChoices):
|
|
CI = 'CI', 'Schedule I'
|
|
CII = 'CII', 'Schedule II'
|
|
CIII = 'CIII', 'Schedule III'
|
|
CIV = 'CIV', 'Schedule IV'
|
|
CV = 'CV', 'Schedule V'
|
|
NON = 'NON', 'Non-Controlled'
|
|
|
|
|
|
class DosageForm(models.TextChoices):
|
|
TABLET = 'TABLET', 'Tablet'
|
|
CAPSULE = 'CAPSULE', 'Capsule'
|
|
LIQUID = 'LIQUID', 'Liquid'
|
|
INJECTION = 'INJECTION', 'Injection'
|
|
TOPICAL = 'TOPICAL', 'Topical'
|
|
INHALER = 'INHALER', 'Inhaler'
|
|
PATCH = 'PATCH', 'Patch'
|
|
SUPPOSITORY = 'SUPPOSITORY', 'Suppository'
|
|
CREAM = 'CREAM', 'Cream'
|
|
OINTMENT = 'OINTMENT', 'Ointment'
|
|
DROPS = 'DROPS', 'Drops'
|
|
SPRAY = 'SPRAY', 'Spray'
|
|
OTHER = 'OTHER', 'Other'
|
|
|
|
class UnitOfMeasure(models.TextChoices):
|
|
MG = 'MG', 'Milligrams'
|
|
G = 'G', 'Grams'
|
|
MCG = 'MCG', 'Micrograms'
|
|
ML = 'ML', 'Milliliters'
|
|
L = 'L', 'Liters'
|
|
UNITS = 'UNITS', 'Units'
|
|
IU = 'IU', 'International Units'
|
|
MEQ = 'MEQ', 'Milliequivalents'
|
|
PERCENT = 'PERCENT', 'Percent'
|
|
OTHER = 'OTHER', 'Other'
|
|
|
|
|
|
class FormularyStatus(models.TextChoices):
|
|
PREFERRED = 'PREFERRED', 'Preferred'
|
|
NON_PREFERRED = 'NON_PREFERRED', 'Non-Preferred'
|
|
RESTRICTED = 'RESTRICTED', 'Restricted'
|
|
NOT_COVERED = 'NOT_COVERED', 'Not Covered'
|
|
PRIOR_AUTH = 'PRIOR_AUTH', 'Prior Authorization Required'
|
|
|
|
# Tenant relationship
|
|
tenant = models.ForeignKey(
|
|
'core.Tenant',
|
|
on_delete=models.CASCADE,
|
|
related_name='medications',
|
|
help_text='Organization tenant'
|
|
)
|
|
|
|
# Medication Information
|
|
medication_id = models.UUIDField(
|
|
default=uuid.uuid4,
|
|
unique=True,
|
|
editable=False,
|
|
help_text='Unique medication identifier'
|
|
)
|
|
|
|
# Drug Identification
|
|
generic_name = models.CharField(
|
|
max_length=200,
|
|
help_text='Generic drug name'
|
|
)
|
|
brand_name = models.CharField(
|
|
max_length=200,
|
|
blank=True,
|
|
null=True,
|
|
help_text='Brand/trade name'
|
|
)
|
|
|
|
# NDC and Coding
|
|
ndc_number = models.CharField(
|
|
max_length=20,
|
|
blank=True,
|
|
null=True,
|
|
validators=[RegexValidator(r'^\d{4,5}-\d{3,4}-\d{1,2}$')],
|
|
help_text='National Drug Code (NDC) number'
|
|
)
|
|
rxcui = models.CharField(
|
|
max_length=20,
|
|
blank=True,
|
|
null=True,
|
|
help_text='RxNorm Concept Unique Identifier'
|
|
)
|
|
|
|
# Drug Classification
|
|
drug_class = models.CharField(
|
|
max_length=100,
|
|
help_text='Therapeutic drug class'
|
|
)
|
|
controlled_substance_schedule = models.CharField(
|
|
max_length=5,
|
|
choices=ControlledSubstanceSchedule.choices,
|
|
default=ControlledSubstanceSchedule.NON,
|
|
help_text='DEA controlled substance schedule'
|
|
)
|
|
|
|
# Formulation
|
|
dosage_form = models.CharField(
|
|
max_length=50,
|
|
choices=DosageForm.choices,
|
|
help_text='Dosage form'
|
|
)
|
|
strength = models.CharField(
|
|
max_length=50,
|
|
help_text='Drug strength (e.g., 500mg, 10mg/ml)'
|
|
)
|
|
unit_of_measure = models.CharField(
|
|
max_length=20,
|
|
choices=UnitOfMeasure.choices,
|
|
help_text='Unit of measure'
|
|
)
|
|
|
|
# Clinical Information
|
|
indications = models.TextField(
|
|
help_text='Clinical indications'
|
|
)
|
|
contraindications = models.TextField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Contraindications'
|
|
)
|
|
side_effects = models.TextField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Common side effects'
|
|
)
|
|
warnings = models.TextField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Warnings and precautions'
|
|
)
|
|
|
|
# Dosing Information
|
|
adult_dose_range = models.CharField(
|
|
max_length=100,
|
|
blank=True,
|
|
null=True,
|
|
help_text='Adult dosing range'
|
|
)
|
|
pediatric_dose_range = models.CharField(
|
|
max_length=100,
|
|
blank=True,
|
|
null=True,
|
|
help_text='Pediatric dosing range'
|
|
)
|
|
max_daily_dose = models.CharField(
|
|
max_length=50,
|
|
blank=True,
|
|
null=True,
|
|
help_text='Maximum daily dose'
|
|
)
|
|
|
|
# Administration
|
|
routes_of_administration = models.JSONField(
|
|
default=list,
|
|
help_text='Routes of administration'
|
|
)
|
|
administration_instructions = models.TextField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Administration instructions'
|
|
)
|
|
|
|
# Storage and Handling
|
|
storage_requirements = models.CharField(
|
|
max_length=100,
|
|
blank=True,
|
|
null=True,
|
|
help_text='Storage requirements'
|
|
)
|
|
special_handling = models.TextField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Special handling requirements'
|
|
)
|
|
|
|
# Formulary Status
|
|
formulary_status = models.CharField(
|
|
max_length=20,
|
|
choices=FormularyStatus.choices,
|
|
default=FormularyStatus.PREFERRED,
|
|
help_text='Formulary status'
|
|
)
|
|
|
|
# Availability
|
|
is_active = models.BooleanField(
|
|
default=True,
|
|
help_text='Medication is active and available'
|
|
)
|
|
is_available = models.BooleanField(
|
|
default=True,
|
|
help_text='Currently available in inventory'
|
|
)
|
|
|
|
# Pricing
|
|
unit_cost = models.DecimalField(
|
|
max_digits=10,
|
|
decimal_places=2,
|
|
blank=True,
|
|
null=True,
|
|
help_text='Cost per unit'
|
|
)
|
|
awp = models.DecimalField(
|
|
max_digits=10,
|
|
decimal_places=2,
|
|
blank=True,
|
|
null=True,
|
|
help_text='Average Wholesale Price'
|
|
)
|
|
|
|
# Manufacturer Information
|
|
manufacturer = models.CharField(
|
|
max_length=100,
|
|
blank=True,
|
|
null=True,
|
|
help_text='Manufacturer name'
|
|
)
|
|
manufacturer_ndc = models.CharField(
|
|
max_length=20,
|
|
blank=True,
|
|
null=True,
|
|
help_text='Manufacturer NDC'
|
|
)
|
|
|
|
# Metadata
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
updated_at = models.DateTimeField(auto_now=True)
|
|
created_by = models.ForeignKey(
|
|
settings.AUTH_USER_MODEL,
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name='created_medications',
|
|
help_text='User who created the medication record'
|
|
)
|
|
|
|
class Meta:
|
|
db_table = 'pharmacy_medication'
|
|
verbose_name = 'Medication'
|
|
verbose_name_plural = 'Medications'
|
|
ordering = ['generic_name']
|
|
indexes = [
|
|
models.Index(fields=['tenant', 'is_active']),
|
|
models.Index(fields=['generic_name']),
|
|
models.Index(fields=['brand_name']),
|
|
models.Index(fields=['ndc_number']),
|
|
models.Index(fields=['drug_class']),
|
|
models.Index(fields=['controlled_substance_schedule']),
|
|
]
|
|
unique_together = ['tenant', 'ndc_number']
|
|
|
|
def __str__(self):
|
|
if self.brand_name:
|
|
return f"{self.generic_name} ({self.brand_name}) {self.strength}"
|
|
return f"{self.generic_name} {self.strength}"
|
|
|
|
@property
|
|
def is_controlled_substance(self):
|
|
"""
|
|
Check if medication is a controlled substance.
|
|
"""
|
|
return self.controlled_substance_schedule != 'NON'
|
|
|
|
@property
|
|
def display_name(self):
|
|
"""
|
|
Get display name for medication.
|
|
"""
|
|
return str(self)
|
|
|
|
|
|
class Prescription(models.Model):
|
|
"""
|
|
Prescription model for electronic prescription management.
|
|
"""
|
|
|
|
class QuantityUnit(models.TextChoices):
|
|
TABLETS = 'TABLETS', 'Tablets'
|
|
CAPSULES = 'CAPSULES', 'Capsules'
|
|
ML = 'ML', 'Milliliters'
|
|
GRAMS = 'GRAMS', 'Grams'
|
|
UNITS = 'UNITS', 'Units'
|
|
PATCHES = 'PATCHES', 'Patches'
|
|
INHALERS = 'INHALERS', 'Inhalers'
|
|
BOTTLES = 'BOTTLES', 'Bottles'
|
|
TUBES = 'TUBES', 'Tubes'
|
|
VIALS = 'VIALS', 'Vials'
|
|
OTHER = 'OTHER', 'Other'
|
|
|
|
class PrescriptionStatus(models.TextChoices):
|
|
PENDING = 'PENDING', 'Pending'
|
|
ACTIVE = 'ACTIVE', 'Active'
|
|
DISPENSED = 'DISPENSED', 'Dispensed'
|
|
PARTIALLY_DISPENSED = 'PARTIALLY_DISPENSED', 'Partially Dispensed'
|
|
COMPLETED = 'COMPLETED', 'Completed'
|
|
CANCELLED = 'CANCELLED', 'Cancelled'
|
|
EXPIRED = 'EXPIRED', 'Expired'
|
|
ON_HOLD = 'ON_HOLD', 'On Hold'
|
|
TRANSFERRED = 'TRANSFERRED', 'Transferred'
|
|
DRAFT = 'DRAFT', 'Draft'
|
|
|
|
# Tenant relationship
|
|
tenant = models.ForeignKey(
|
|
'core.Tenant',
|
|
on_delete=models.CASCADE,
|
|
related_name='prescriptions',
|
|
help_text='Organization tenant'
|
|
)
|
|
|
|
# Prescription Information
|
|
prescription_id = models.UUIDField(
|
|
default=uuid.uuid4,
|
|
unique=True,
|
|
editable=False,
|
|
help_text='Unique prescription identifier'
|
|
)
|
|
prescription_number = models.CharField(
|
|
max_length=20,
|
|
unique=True,
|
|
help_text='Prescription number'
|
|
)
|
|
|
|
# Patient and Provider
|
|
patient = models.ForeignKey(
|
|
'patients.PatientProfile',
|
|
on_delete=models.CASCADE,
|
|
related_name='prescriptions',
|
|
help_text='Patient'
|
|
)
|
|
prescriber = models.ForeignKey(
|
|
settings.AUTH_USER_MODEL,
|
|
on_delete=models.CASCADE,
|
|
related_name='prescribed_medications',
|
|
help_text='Prescribing provider'
|
|
)
|
|
|
|
# Medication
|
|
medication = models.ForeignKey(
|
|
Medication,
|
|
on_delete=models.CASCADE,
|
|
related_name='prescriptions',
|
|
help_text='Prescribed medication'
|
|
)
|
|
|
|
# Prescription Details
|
|
quantity_prescribed = models.PositiveIntegerField(
|
|
help_text='Quantity prescribed'
|
|
)
|
|
quantity_unit = models.CharField(
|
|
max_length=20,
|
|
choices=QuantityUnit.choices,
|
|
help_text='Unit of quantity'
|
|
)
|
|
|
|
# Dosing Instructions
|
|
dosage_instructions = models.TextField(
|
|
help_text='Dosing instructions (SIG)'
|
|
)
|
|
frequency = models.CharField(
|
|
max_length=50,
|
|
help_text='Frequency of administration'
|
|
)
|
|
duration = models.CharField(
|
|
max_length=50,
|
|
blank=True,
|
|
null=True,
|
|
help_text='Duration of therapy'
|
|
)
|
|
|
|
# Refills
|
|
refills_authorized = models.PositiveIntegerField(
|
|
default=0,
|
|
help_text='Number of refills authorized'
|
|
)
|
|
refills_remaining = models.PositiveIntegerField(
|
|
default=0,
|
|
help_text='Number of refills remaining'
|
|
)
|
|
|
|
# Dates
|
|
date_prescribed = models.DateTimeField(
|
|
default=timezone.now,
|
|
help_text='Date and time prescribed'
|
|
)
|
|
date_written = models.DateField(
|
|
help_text='Date written on prescription'
|
|
)
|
|
expiration_date = models.DateField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Prescription expiration date'
|
|
)
|
|
|
|
# Status
|
|
status = models.CharField(
|
|
max_length=20,
|
|
choices=PrescriptionStatus.choices,
|
|
default=PrescriptionStatus.PENDING,
|
|
help_text='Prescription status'
|
|
)
|
|
|
|
# Clinical Information
|
|
indication = models.CharField(
|
|
max_length=200,
|
|
blank=True,
|
|
null=True,
|
|
help_text='Indication for prescription'
|
|
)
|
|
diagnosis_code = models.CharField(
|
|
max_length=20,
|
|
blank=True,
|
|
null=True,
|
|
help_text='ICD-10 diagnosis code'
|
|
)
|
|
|
|
# Special Instructions
|
|
pharmacy_notes = models.TextField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Notes for pharmacy'
|
|
)
|
|
patient_instructions = models.TextField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Patient instructions'
|
|
)
|
|
|
|
# Prior Authorization
|
|
prior_authorization_required = models.BooleanField(
|
|
default=False,
|
|
help_text='Prior authorization required'
|
|
)
|
|
prior_authorization_number = models.CharField(
|
|
max_length=50,
|
|
blank=True,
|
|
null=True,
|
|
help_text='Prior authorization number'
|
|
)
|
|
prior_authorization_expiry = models.DateField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Prior authorization expiry date'
|
|
)
|
|
|
|
# Generic Substitution
|
|
generic_substitution_allowed = models.BooleanField(
|
|
default=True,
|
|
help_text='Generic substitution allowed'
|
|
)
|
|
dispense_as_written = models.BooleanField(
|
|
default=False,
|
|
help_text='Dispense as written (DAW)'
|
|
)
|
|
|
|
# Electronic Prescription
|
|
electronic_prescription = models.BooleanField(
|
|
default=True,
|
|
help_text='Electronic prescription'
|
|
)
|
|
e_prescription_id = models.CharField(
|
|
max_length=50,
|
|
blank=True,
|
|
null=True,
|
|
help_text='Electronic prescription ID'
|
|
)
|
|
|
|
# Related Information
|
|
encounter = models.ForeignKey(
|
|
'emr.Encounter',
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name='prescriptions',
|
|
help_text='Related encounter'
|
|
)
|
|
|
|
# Verification
|
|
verified = models.BooleanField(
|
|
default=False,
|
|
help_text='Prescription has been verified'
|
|
)
|
|
verified_by = models.ForeignKey(
|
|
settings.AUTH_USER_MODEL,
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name='verified_prescriptions',
|
|
help_text='Pharmacist who verified prescription'
|
|
)
|
|
verified_datetime = models.DateTimeField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Date and time of verification'
|
|
)
|
|
|
|
# Insurance Approval Integration
|
|
approval_requests = GenericRelation(
|
|
'insurance_approvals.InsuranceApprovalRequest',
|
|
content_type_field='content_type',
|
|
object_id_field='object_id',
|
|
related_query_name='prescription'
|
|
)
|
|
|
|
# Metadata
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
updated_at = models.DateTimeField(auto_now=True)
|
|
|
|
class Meta:
|
|
db_table = 'pharmacy_prescription'
|
|
verbose_name = 'Prescription'
|
|
verbose_name_plural = 'Prescriptions'
|
|
ordering = ['-date_prescribed']
|
|
indexes = [
|
|
models.Index(fields=['tenant', 'status']),
|
|
models.Index(fields=['patient', 'status']),
|
|
models.Index(fields=['prescriber']),
|
|
models.Index(fields=['medication']),
|
|
models.Index(fields=['date_prescribed']),
|
|
models.Index(fields=['prescription_number']),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"{self.prescription_number} - {self.patient.get_full_name()} - {self.medication.display_name}"
|
|
|
|
def save(self, *args, **kwargs):
|
|
"""
|
|
Generate prescription number if not provided.
|
|
"""
|
|
if not self.prescription_number:
|
|
# Generate prescription number (simple implementation)
|
|
last_rx = Prescription.objects.filter(tenant=self.tenant).order_by('-id').first()
|
|
if last_rx:
|
|
last_number = int(last_rx.prescription_number.split('-')[-1])
|
|
self.prescription_number = f"RX-{self.tenant.id}-{last_number + 1:06d}"
|
|
else:
|
|
self.prescription_number = f"RX-{self.tenant.id}-000001"
|
|
|
|
# Set expiration date if not provided
|
|
if not self.expiration_date:
|
|
if self.medication.is_controlled_substance:
|
|
# Controlled substances expire in 6 months
|
|
self.expiration_date = self.date_written + timedelta(days=180)
|
|
else:
|
|
# Non-controlled substances expire in 1 year
|
|
self.expiration_date = self.date_written + timedelta(days=365)
|
|
|
|
# Set refills remaining
|
|
if self.refills_remaining == 0 and self.refills_authorized > 0:
|
|
self.refills_remaining = self.refills_authorized
|
|
|
|
super().save(*args, **kwargs)
|
|
|
|
@property
|
|
def is_expired(self):
|
|
"""
|
|
Check if prescription is expired.
|
|
"""
|
|
return timezone.now().date() > self.expiration_date
|
|
|
|
@property
|
|
def days_until_expiry(self):
|
|
"""
|
|
Calculate days until expiry.
|
|
"""
|
|
if self.expiration_date:
|
|
return (self.expiration_date - timezone.now().date()).days
|
|
return None
|
|
|
|
def has_valid_approval(self):
|
|
"""
|
|
Check if prescription has a valid insurance approval.
|
|
"""
|
|
from django.utils import timezone
|
|
return self.approval_requests.filter(
|
|
status__in=['APPROVED', 'PARTIALLY_APPROVED'],
|
|
expiration_date__gte=timezone.now().date()
|
|
).exists()
|
|
|
|
def get_active_approval(self):
|
|
"""
|
|
Get the active insurance approval for this prescription.
|
|
"""
|
|
from django.utils import timezone
|
|
return self.approval_requests.filter(
|
|
status__in=['APPROVED', 'PARTIALLY_APPROVED'],
|
|
expiration_date__gte=timezone.now().date()
|
|
).first()
|
|
|
|
def requires_approval(self):
|
|
"""
|
|
Check if prescription requires insurance approval.
|
|
Returns True if patient has insurance and no valid approval exists.
|
|
Also checks if prior authorization is required.
|
|
"""
|
|
if not self.patient.insurance_info.exists():
|
|
return False
|
|
|
|
# Check if prior authorization is explicitly required
|
|
if self.prior_authorization_required and not self.prior_authorization_number:
|
|
return True
|
|
|
|
# Check if medication requires approval based on formulary status
|
|
if self.medication.formulary_status in ['RESTRICTED', 'PRIOR_AUTH']:
|
|
return not self.has_valid_approval()
|
|
|
|
return False
|
|
|
|
@property
|
|
def approval_status(self):
|
|
"""
|
|
Get current approval status for display.
|
|
"""
|
|
if not self.patient.insurance_info.exists():
|
|
return 'NO_INSURANCE'
|
|
|
|
# Check prior authorization
|
|
if self.prior_authorization_required:
|
|
if self.prior_authorization_number:
|
|
# Check if prior auth is expired
|
|
if self.prior_authorization_expiry and self.prior_authorization_expiry < timezone.now().date():
|
|
return 'PRIOR_AUTH_EXPIRED'
|
|
return 'PRIOR_AUTH_APPROVED'
|
|
return 'PRIOR_AUTH_REQUIRED'
|
|
|
|
# Check approval requests
|
|
latest_approval = self.approval_requests.order_by('-created_at').first()
|
|
if not latest_approval:
|
|
if self.medication.formulary_status in ['RESTRICTED', 'PRIOR_AUTH']:
|
|
return 'APPROVAL_REQUIRED'
|
|
return 'NO_APPROVAL_NEEDED'
|
|
|
|
if self.has_valid_approval():
|
|
return 'APPROVED'
|
|
|
|
return latest_approval.status
|
|
|
|
|
|
class MedicationInventoryItem(models.Model):
|
|
"""
|
|
Bridge model linking medications to centralized inventory system.
|
|
This model provides medication-specific metadata for inventory items.
|
|
"""
|
|
|
|
# Tenant relationship
|
|
tenant = models.ForeignKey(
|
|
'core.Tenant',
|
|
on_delete=models.CASCADE,
|
|
related_name='medication_inventory_items',
|
|
help_text='Organization tenant'
|
|
)
|
|
|
|
# Medication relationship
|
|
medication = models.ForeignKey(
|
|
Medication,
|
|
on_delete=models.CASCADE,
|
|
related_name='medication_inventory_items',
|
|
help_text='Related medication'
|
|
)
|
|
|
|
# Centralized inventory item relationship
|
|
inventory_item = models.ForeignKey(
|
|
'inventory.InventoryItem',
|
|
on_delete=models.CASCADE,
|
|
related_name='medication_inventory_items',
|
|
help_text='Centralized inventory item'
|
|
)
|
|
|
|
# Medication-specific information
|
|
medication_inventory_id = models.UUIDField(
|
|
default=uuid.uuid4,
|
|
unique=True,
|
|
editable=False,
|
|
help_text='Unique medication inventory identifier'
|
|
)
|
|
|
|
# Pharmacy-specific fields
|
|
formulary_tier = models.CharField(
|
|
max_length=20,
|
|
blank=True,
|
|
null=True,
|
|
help_text='Formulary tier for this medication'
|
|
)
|
|
|
|
therapeutic_equivalent = models.BooleanField(
|
|
default=False,
|
|
help_text='Therapeutic equivalent available'
|
|
)
|
|
|
|
auto_substitution_allowed = models.BooleanField(
|
|
default=True,
|
|
help_text='Automatic substitution allowed'
|
|
)
|
|
|
|
pharmacy_notes = models.TextField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Pharmacy-specific notes'
|
|
)
|
|
|
|
# Dispensing information
|
|
max_dispense_quantity = models.PositiveIntegerField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Maximum quantity that can be dispensed at once'
|
|
)
|
|
|
|
requires_counseling = models.BooleanField(
|
|
default=False,
|
|
help_text='Requires patient counseling'
|
|
)
|
|
|
|
requires_id_verification = models.BooleanField(
|
|
default=False,
|
|
help_text='Requires ID verification for pickup'
|
|
)
|
|
|
|
# Metadata
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
updated_at = models.DateTimeField(auto_now=True)
|
|
created_by = models.ForeignKey(
|
|
settings.AUTH_USER_MODEL,
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name='created_medication_inventory_items',
|
|
help_text='User who created the medication inventory item'
|
|
)
|
|
|
|
class Meta:
|
|
db_table = 'pharmacy_medication_inventory_item'
|
|
verbose_name = 'Medication Inventory Item'
|
|
verbose_name_plural = 'Medication Inventory Items'
|
|
ordering = ['medication__generic_name']
|
|
indexes = [
|
|
models.Index(fields=['tenant', 'medication']),
|
|
models.Index(fields=['inventory_item']),
|
|
models.Index(fields=['medication']),
|
|
]
|
|
unique_together = ['tenant', 'medication', 'inventory_item']
|
|
|
|
def __str__(self):
|
|
return f"{self.medication.display_name} -> {self.inventory_item.item_name}"
|
|
|
|
@property
|
|
def current_stock(self):
|
|
"""
|
|
Get current stock from related inventory stocks.
|
|
"""
|
|
return sum(
|
|
stock.quantity_on_hand
|
|
for stock in self.inventory_item.inventory_stocks.filter(
|
|
quality_status='GOOD'
|
|
)
|
|
)
|
|
|
|
@property
|
|
def available_stock(self):
|
|
"""
|
|
Get available stock from related inventory stocks.
|
|
"""
|
|
return sum(
|
|
stock.quantity_available
|
|
for stock in self.inventory_item.inventory_stocks.filter(
|
|
quality_status='GOOD'
|
|
)
|
|
)
|
|
|
|
@property
|
|
def needs_reorder(self):
|
|
"""
|
|
Check if medication needs reordering.
|
|
"""
|
|
return self.current_stock <= self.inventory_item.reorder_point
|
|
|
|
|
|
# Legacy alias for backward compatibility during migration
|
|
InventoryItem = MedicationInventoryItem
|
|
|
|
|
|
class DispenseRecord(models.Model):
|
|
"""
|
|
Dispense record model for tracking medication dispensing.
|
|
"""
|
|
class DispenseStatus(models.TextChoices):
|
|
DISPENSED = 'DISPENSED', 'Dispensed'
|
|
PICKED_UP = 'PICKED_UP', 'Picked Up'
|
|
RETURNED = 'RETURNED', 'Returned'
|
|
CANCELLED = 'CANCELLED', 'Cancelled'
|
|
|
|
# Prescription relationship
|
|
prescription = models.ForeignKey(
|
|
Prescription,
|
|
on_delete=models.CASCADE,
|
|
related_name='dispense_records',
|
|
help_text='Related prescription'
|
|
)
|
|
|
|
# Inventory stock from centralized system
|
|
inventory_stock = models.ForeignKey(
|
|
'inventory.InventoryStock',
|
|
on_delete=models.CASCADE,
|
|
related_name='dispense_records',
|
|
help_text='Inventory stock dispensed from'
|
|
)
|
|
|
|
# Dispense Information
|
|
dispense_id = models.UUIDField(
|
|
default=uuid.uuid4,
|
|
unique=True,
|
|
editable=False,
|
|
help_text='Unique dispense identifier'
|
|
)
|
|
|
|
# Quantity
|
|
quantity_dispensed = models.PositiveIntegerField(
|
|
help_text='Quantity dispensed'
|
|
)
|
|
quantity_remaining = models.PositiveIntegerField(
|
|
help_text='Quantity remaining on prescription'
|
|
)
|
|
|
|
# Dates
|
|
date_dispensed = models.DateTimeField(
|
|
default=timezone.now,
|
|
help_text='Date and time dispensed'
|
|
)
|
|
|
|
# Staff
|
|
dispensed_by = models.ForeignKey(
|
|
settings.AUTH_USER_MODEL,
|
|
on_delete=models.CASCADE,
|
|
related_name='dispensed_medications',
|
|
help_text='Pharmacist who dispensed medication'
|
|
)
|
|
verified_by = models.ForeignKey(
|
|
settings.AUTH_USER_MODEL,
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name='verified_dispenses',
|
|
help_text='Pharmacist who verified dispense'
|
|
)
|
|
|
|
# Pricing
|
|
unit_price = models.DecimalField(
|
|
max_digits=10,
|
|
decimal_places=2,
|
|
help_text='Price per unit'
|
|
)
|
|
total_price = models.DecimalField(
|
|
max_digits=10,
|
|
decimal_places=2,
|
|
help_text='Total price'
|
|
)
|
|
copay_amount = models.DecimalField(
|
|
max_digits=8,
|
|
decimal_places=2,
|
|
default=Decimal('0.00'),
|
|
help_text='Patient copay amount'
|
|
)
|
|
insurance_amount = models.DecimalField(
|
|
max_digits=10,
|
|
decimal_places=2,
|
|
default=Decimal('0.00'),
|
|
help_text='Insurance payment amount'
|
|
)
|
|
|
|
# Patient Information
|
|
patient_counseled = models.BooleanField(
|
|
default=False,
|
|
help_text='Patient was counseled'
|
|
)
|
|
counseling_notes = models.TextField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Counseling notes'
|
|
)
|
|
|
|
# Refill Information
|
|
is_refill = models.BooleanField(
|
|
default=False,
|
|
help_text='This is a refill'
|
|
)
|
|
refill_number = models.PositiveIntegerField(
|
|
default=0,
|
|
help_text='Refill number'
|
|
)
|
|
|
|
# Status
|
|
status = models.CharField(
|
|
max_length=20,
|
|
choices=DispenseStatus.choices,
|
|
default=DispenseStatus.DISPENSED,
|
|
help_text='Dispense status'
|
|
)
|
|
|
|
# Pickup Information
|
|
picked_up_by = models.CharField(
|
|
max_length=100,
|
|
blank=True,
|
|
null=True,
|
|
help_text='Person who picked up medication'
|
|
)
|
|
pickup_datetime = models.DateTimeField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Date and time of pickup'
|
|
)
|
|
identification_verified = models.BooleanField(
|
|
default=False,
|
|
help_text='Identification was verified'
|
|
)
|
|
|
|
# Quality Control
|
|
quality_check_performed = models.BooleanField(
|
|
default=False,
|
|
help_text='Quality check performed'
|
|
)
|
|
quality_notes = models.TextField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Quality control notes'
|
|
)
|
|
|
|
# Metadata
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
updated_at = models.DateTimeField(auto_now=True)
|
|
|
|
class Meta:
|
|
db_table = 'pharmacy_dispense_record'
|
|
verbose_name = 'Dispense Record'
|
|
verbose_name_plural = 'Dispense Records'
|
|
ordering = ['-date_dispensed']
|
|
indexes = [
|
|
models.Index(fields=['prescription']),
|
|
models.Index(fields=['inventory_stock']),
|
|
models.Index(fields=['date_dispensed']),
|
|
models.Index(fields=['dispensed_by']),
|
|
models.Index(fields=['status']),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"{self.prescription.prescription_number} - {self.quantity_dispensed} units"
|
|
|
|
def save(self, *args, **kwargs):
|
|
"""
|
|
Calculate total price and update prescription status.
|
|
"""
|
|
self.total_price = self.quantity_dispensed * self.unit_price
|
|
|
|
# Update prescription quantity remaining
|
|
if self.prescription:
|
|
total_dispensed = self.prescription.dispense_records.aggregate(
|
|
total=models.Sum('quantity_dispensed')
|
|
)['total'] or 0
|
|
|
|
if not self.pk: # New dispense record
|
|
total_dispensed += self.quantity_dispensed
|
|
|
|
self.quantity_remaining = max(0, self.prescription.quantity_prescribed - total_dispensed)
|
|
|
|
# Update prescription status
|
|
if self.quantity_remaining == 0:
|
|
self.prescription.status = 'COMPLETED'
|
|
elif total_dispensed > 0:
|
|
self.prescription.status = 'PARTIALLY_DISPENSED'
|
|
else:
|
|
self.prescription.status = 'DISPENSED'
|
|
|
|
self.prescription.save()
|
|
|
|
super().save(*args, **kwargs)
|
|
|
|
@property
|
|
def patient(self):
|
|
"""
|
|
Get patient from prescription.
|
|
"""
|
|
return self.prescription.patient
|
|
|
|
@property
|
|
def medication(self):
|
|
"""
|
|
Get medication from inventory stock.
|
|
"""
|
|
return self.inventory_stock.inventory_item.medication_inventory_items.first().medication
|
|
|
|
|
|
class MedicationAdministration(models.Model):
|
|
"""
|
|
Medication Administration Record (MAR) for inpatient medication tracking.
|
|
"""
|
|
|
|
class RouteGiven(models.TextChoices):
|
|
PO = 'PO', 'Oral'
|
|
IV = 'IV', 'Intravenous'
|
|
IM = 'IM', 'Intramuscular'
|
|
SC = 'SC', 'Subcutaneous'
|
|
SL = 'SL', 'Sublingual'
|
|
TOP = 'TOP', 'Topical'
|
|
INH = 'INH', 'Inhalation'
|
|
PR = 'PR', 'Rectal'
|
|
PV = 'PV', 'Vaginal'
|
|
NASAL = 'NASAL', 'Nasal'
|
|
OPTH = 'OPTH', 'Ophthalmic'
|
|
OTIC = 'OTIC', 'Otic'
|
|
OTHER = 'OTHER', 'Other'
|
|
|
|
class MedicationStatus(models.TextChoices):
|
|
SCHEDULED = 'SCHEDULED', 'Scheduled'
|
|
GIVEN = 'GIVEN', 'Given'
|
|
NOT_GIVEN = 'NOT_GIVEN', 'Not Given'
|
|
HELD = 'HELD', 'Held'
|
|
REFUSED = 'REFUSED', 'Refused'
|
|
OMITTED = 'OMITTED', 'Omitted'
|
|
|
|
class ReasonNotGiven(models.TextChoices):
|
|
PATIENT_REFUSED = 'PATIENT_REFUSED', 'Patient Refused'
|
|
PATIENT_UNAVAILABLE = 'PATIENT_UNAVAILABLE', 'Patient Unavailable'
|
|
MEDICATION_UNAVAILABLE = 'MEDICATION_UNAVAILABLE', 'Medication Unavailable'
|
|
HELD_BY_PROVIDER = 'HELD_BY_PROVIDER', 'Held by Provider'
|
|
PATIENT_NPO = 'PATIENT_NPO', 'Patient NPO'
|
|
PATIENT_ASLEEP = 'PATIENT_ASLEEP', 'Patient Asleep'
|
|
ADVERSE_REACTION = 'ADVERSE_REACTION', 'Adverse Reaction'
|
|
OTHER = 'OTHER', 'Other'
|
|
|
|
# Prescription relationship
|
|
prescription = models.ForeignKey(
|
|
Prescription,
|
|
on_delete=models.CASCADE,
|
|
related_name='administration_records',
|
|
help_text='Related prescription'
|
|
)
|
|
|
|
# Patient and Encounter
|
|
patient = models.ForeignKey(
|
|
'patients.PatientProfile',
|
|
on_delete=models.CASCADE,
|
|
related_name='medication_administrations',
|
|
help_text='Patient'
|
|
)
|
|
encounter = models.ForeignKey(
|
|
'emr.Encounter',
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name='medication_administrations',
|
|
help_text='Related encounter'
|
|
)
|
|
|
|
# Administration Information
|
|
administration_id = models.UUIDField(
|
|
default=uuid.uuid4,
|
|
unique=True,
|
|
editable=False,
|
|
help_text='Unique administration identifier'
|
|
)
|
|
|
|
# Scheduled vs Actual
|
|
scheduled_datetime = models.DateTimeField(
|
|
help_text='Scheduled administration time'
|
|
)
|
|
actual_datetime = models.DateTimeField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Actual administration time'
|
|
)
|
|
|
|
# Dosage
|
|
dose_given = models.CharField(
|
|
max_length=50,
|
|
help_text='Dose given'
|
|
)
|
|
route_given = models.CharField(
|
|
max_length=30,
|
|
choices=RouteGiven.choices,
|
|
help_text='Route of administration'
|
|
)
|
|
|
|
# Status
|
|
status = models.CharField(
|
|
max_length=20,
|
|
choices=MedicationStatus.choices,
|
|
default=MedicationStatus.SCHEDULED,
|
|
help_text='Administration status'
|
|
)
|
|
|
|
# Staff
|
|
administered_by = models.ForeignKey(
|
|
settings.AUTH_USER_MODEL,
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name='administered_medications',
|
|
help_text='Nurse who administered medication'
|
|
)
|
|
witnessed_by = models.ForeignKey(
|
|
settings.AUTH_USER_MODEL,
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name='witnessed_administrations',
|
|
help_text='Witness (for controlled substances)'
|
|
)
|
|
|
|
# Reason for Not Given
|
|
reason_not_given = models.CharField(
|
|
max_length=100,
|
|
blank=True,
|
|
null=True,
|
|
choices=ReasonNotGiven.choices,
|
|
help_text='Reason medication was not given'
|
|
)
|
|
reason_notes = models.TextField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Additional notes about reason'
|
|
)
|
|
|
|
# Patient Response
|
|
patient_response = models.TextField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Patient response to medication'
|
|
)
|
|
side_effects_observed = models.TextField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Side effects observed'
|
|
)
|
|
|
|
# Site Information (for injections)
|
|
injection_site = models.CharField(
|
|
max_length=50,
|
|
blank=True,
|
|
null=True,
|
|
help_text='Injection site'
|
|
)
|
|
site_condition = models.CharField(
|
|
max_length=100,
|
|
blank=True,
|
|
null=True,
|
|
help_text='Condition of injection site'
|
|
)
|
|
|
|
# Verification
|
|
double_checked = models.BooleanField(
|
|
default=False,
|
|
help_text='Medication was double-checked'
|
|
)
|
|
double_checked_by = models.ForeignKey(
|
|
settings.AUTH_USER_MODEL,
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name='double_checked_administrations',
|
|
help_text='Second nurse who verified medication'
|
|
)
|
|
|
|
# Metadata
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
updated_at = models.DateTimeField(auto_now=True)
|
|
|
|
class Meta:
|
|
db_table = 'pharmacy_medication_administration'
|
|
verbose_name = 'Medication Administration'
|
|
verbose_name_plural = 'Medication Administrations'
|
|
ordering = ['-scheduled_datetime']
|
|
indexes = [
|
|
models.Index(fields=['patient', 'scheduled_datetime']),
|
|
models.Index(fields=['prescription']),
|
|
models.Index(fields=['encounter']),
|
|
models.Index(fields=['status']),
|
|
models.Index(fields=['administered_by']),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"{self.patient.get_full_name()} - {self.prescription.medication.display_name} - {self.scheduled_datetime}"
|
|
|
|
@property
|
|
def is_overdue(self):
|
|
"""
|
|
Check if administration is overdue.
|
|
"""
|
|
if self.status == 'SCHEDULED':
|
|
return timezone.now() > self.scheduled_datetime + timedelta(minutes=30)
|
|
return False
|
|
|
|
@property
|
|
def medication(self):
|
|
"""
|
|
Get medication from prescription.
|
|
"""
|
|
return self.prescription.medication
|
|
|
|
|
|
class DrugInteraction(models.Model):
|
|
"""
|
|
Drug interaction model for clinical decision support.
|
|
"""
|
|
|
|
class Severity(models.TextChoices):
|
|
MINOR = 'MINOR', 'Minor'
|
|
MODERATE = 'MODERATE', 'Moderate'
|
|
MAJOR = 'MAJOR', 'Major'
|
|
CONTRAINDICATED = 'CONTRAINDICATED', 'Contraindicated'
|
|
|
|
class InteractionType(models.TextChoices):
|
|
PHARMACOKINETIC = 'PHARMACOKINETIC', 'Pharmacokinetic'
|
|
PHARMACODYNAMIC = 'PHARMACODYNAMIC', 'Pharmacodynamic'
|
|
ADDITIVE = 'ADDITIVE', 'Additive'
|
|
SYNERGISTIC = 'SYNERGISTIC', 'Synergistic'
|
|
ANTAGONISTIC = 'ANTAGONISTIC', 'Antagonistic'
|
|
OTHER = 'OTHER', 'Other'
|
|
|
|
class EvidenceLevel(models.TextChoices):
|
|
ESTABLISHED = 'ESTABLISHED', 'Established'
|
|
PROBABLE = 'PROBABLE', 'Probable'
|
|
SUSPECTED = 'SUSPECTED', 'Suspected'
|
|
POSSIBLE = 'POSSIBLE', 'Possible'
|
|
UNLIKELY = 'UNLIKELY', 'Unlikely'
|
|
|
|
# Tenant relationship
|
|
tenant = models.ForeignKey(
|
|
'core.Tenant',
|
|
on_delete=models.CASCADE,
|
|
related_name='drug_interactions',
|
|
help_text='Organization tenant'
|
|
)
|
|
|
|
# Medications
|
|
medication_1 = models.ForeignKey(
|
|
Medication,
|
|
on_delete=models.CASCADE,
|
|
related_name='interactions_as_drug1',
|
|
help_text='First medication'
|
|
)
|
|
medication_2 = models.ForeignKey(
|
|
Medication,
|
|
on_delete=models.CASCADE,
|
|
related_name='interactions_as_drug2',
|
|
help_text='Second medication'
|
|
)
|
|
|
|
# Interaction Information
|
|
interaction_id = models.UUIDField(
|
|
default=uuid.uuid4,
|
|
unique=True,
|
|
editable=False,
|
|
help_text='Unique interaction identifier'
|
|
)
|
|
|
|
# Severity
|
|
severity = models.CharField(
|
|
max_length=20,
|
|
choices=Severity.choices,
|
|
help_text='Interaction severity'
|
|
)
|
|
|
|
# Interaction Details
|
|
interaction_type = models.CharField(
|
|
max_length=30,
|
|
choices=InteractionType.choices,
|
|
help_text='Type of interaction'
|
|
)
|
|
|
|
mechanism = models.TextField(
|
|
help_text='Mechanism of interaction'
|
|
)
|
|
clinical_effect = models.TextField(
|
|
help_text='Clinical effect of interaction'
|
|
)
|
|
|
|
# Management
|
|
management_recommendations = models.TextField(
|
|
help_text='Management recommendations'
|
|
)
|
|
monitoring_parameters = models.TextField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Parameters to monitor'
|
|
)
|
|
|
|
# Evidence
|
|
evidence_level = models.CharField(
|
|
max_length=20,
|
|
choices=EvidenceLevel.choices,
|
|
help_text='Level of evidence'
|
|
)
|
|
|
|
# References
|
|
references = models.TextField(
|
|
blank=True,
|
|
null=True,
|
|
help_text='Literature references'
|
|
)
|
|
|
|
# Status
|
|
is_active = models.BooleanField(
|
|
default=True,
|
|
help_text='Interaction is active'
|
|
)
|
|
|
|
# Metadata
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
updated_at = models.DateTimeField(auto_now=True)
|
|
created_by = models.ForeignKey(
|
|
settings.AUTH_USER_MODEL,
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name='created_drug_interactions',
|
|
help_text='User who created the interaction record'
|
|
)
|
|
|
|
class Meta:
|
|
db_table = 'pharmacy_drug_interaction'
|
|
verbose_name = 'Drug Interaction'
|
|
verbose_name_plural = 'Drug Interactions'
|
|
ordering = ['-severity', 'medication_1__generic_name']
|
|
indexes = [
|
|
models.Index(fields=['tenant', 'is_active']),
|
|
models.Index(fields=['medication_1']),
|
|
models.Index(fields=['medication_2']),
|
|
models.Index(fields=['severity']),
|
|
]
|
|
unique_together = ['tenant', 'medication_1', 'medication_2']
|
|
|
|
def __str__(self):
|
|
return f"{self.medication_1.display_name} + {self.medication_2.display_name} ({self.severity})"
|
|
|
|
@property
|
|
def is_major_interaction(self):
|
|
"""
|
|
Check if interaction is major or contraindicated.
|
|
"""
|
|
return self.severity in ['MAJOR', 'CONTRAINDICATED']
|