HH/apps/organizations/management/commands/migrate_staff_departments.py
2026-02-22 08:35:53 +03:00

392 lines
17 KiB
Python

"""
Management command to populate staff.department ForeignKey from department_name text field.
This command:
1. Finds all staff with department_name text but NULL department ForeignKey
2. Matches department_name to actual Department records
3. Updates the department ForeignKey
4. Optionally creates missing departments with --create-missing
5. Optionally deletes and recreates all departments with --force-create
Usage:
python manage.py migrate_staff_departments
python manage.py migrate_staff_departments --dry-run
python manage.py migrate_staff_departments --hospital-code=H001
python manage.py migrate_staff_departments --create-missing
python manage.py migrate_staff_departments --force-create
python manage.py migrate_staff_departments --force-create --no-confirm
"""
from django.core.management.base import BaseCommand
from django.db import transaction
from apps.organizations.models import Staff, Department
class Command(BaseCommand):
help = 'Populate staff.department ForeignKey from department_name text field'
def add_arguments(self, parser):
parser.add_argument(
'--dry-run',
action='store_true',
dest='dry_run',
default=False,
help='Show what would be updated without making changes',
)
parser.add_argument(
'--hospital-code',
dest='hospital_code',
default=None,
help='Only process staff from this hospital code',
)
parser.add_argument(
'--fuzzy',
action='store_true',
dest='fuzzy_match',
default=False,
help='Also try fuzzy matching for department names',
)
parser.add_argument(
'--create-missing',
action='store_true',
dest='create_missing',
default=False,
help='Create missing departments if they do not exist (get-or-create)',
)
parser.add_argument(
'--force-create',
action='store_true',
dest='force_create',
default=False,
help='Delete all existing departments and recreate from department_name (DESTRUCTIVE)',
)
parser.add_argument(
'--no-confirm',
action='store_true',
dest='no_confirm',
default=False,
help='Skip confirmation prompt for --force-create',
)
def handle(self, *args, **options):
dry_run = options['dry_run']
hospital_code = options['hospital_code']
fuzzy_match = options['fuzzy_match']
create_missing = options['create_missing']
force_create = options['force_create']
no_confirm = options['no_confirm']
# Force-create mode: delete and recreate all departments
if force_create:
self.handle_force_create(
dry_run=dry_run,
hospital_code=hospital_code,
no_confirm=no_confirm
)
return
# Normal mode: match/create missing
# Build queryset
staff_qs = Staff.objects.select_related('hospital').filter(
department__isnull=True, # ForeignKey is NULL
department_name__isnull=False, # Text field has value
).exclude(
department_name='' # Exclude empty strings
)
if hospital_code:
staff_qs = staff_qs.filter(hospital__code=hospital_code)
self.stdout.write(f"Filtering by hospital code: {hospital_code}")
total_count = staff_qs.count()
self.stdout.write(f"Found {total_count} staff records with department_name but no department ForeignKey")
if total_count == 0:
self.stdout.write(self.style.SUCCESS("No staff records need migration."))
return
# Statistics
exact_matches = 0
fuzzy_matches = 0
no_match = 0
multiple_matches = 0
created_departments = 0
created_count = 0
# Track unmatched department names for reporting
unmatched_departments = set()
ambiguous_matches = {}
# Track created departments to avoid duplicates
created_dept_cache = {}
for staff in staff_qs:
dept_name = staff.department_name.strip()
hospital_id = staff.hospital_id
# Try exact match (case-insensitive)
exact_dept = Department.objects.filter(
hospital_id=hospital_id,
name__iexact=dept_name,
status='active'
).first()
if exact_dept:
exact_matches += 1
if not dry_run:
staff.department = exact_dept
staff.save(update_fields=['department'])
self.stdout.write(f" ✓ EXACT: {staff.get_full_name()} -> {exact_dept.name}")
continue
# Try partial match if fuzzy enabled
if fuzzy_match:
partial_depts = Department.objects.filter(
hospital_id=hospital_id,
name__icontains=dept_name,
status='active'
)
if partial_depts.count() == 1:
fuzzy_matches += 1
matched_dept = partial_depts.first()
if not dry_run:
staff.department = matched_dept
staff.save(update_fields=['department'])
self.stdout.write(f" ~ FUZZY: {staff.get_full_name()} -> {matched_dept.name} (from '{dept_name}')")
continue
elif partial_depts.count() > 1:
multiple_matches += 1
ambiguous_matches[staff.id] = {
'staff_name': staff.get_full_name(),
'department_name': dept_name,
'matches': [d.name for d in partial_depts]
}
self.stdout.write(f" ? MULTIPLE: {staff.get_full_name()} '{dept_name}' matches: {[d.name for d in partial_depts]}")
continue
# No match found - check if we should create it
if create_missing:
# Check cache first to avoid creating duplicates in same run
cache_key = (hospital_id, dept_name.lower())
if cache_key in created_dept_cache:
# Use already created department from this run
new_dept = created_dept_cache[cache_key]
created_count += 1
if not dry_run:
staff.department = new_dept
staff.save(update_fields=['department'])
self.stdout.write(f" + REUSE CREATED: {staff.get_full_name()} -> {new_dept.name}")
continue
# Get or create the department
# Generate a code from the department name
code = dept_name.upper().replace(' ', '_').replace('-', '_')[:20]
# Ensure code is unique by adding suffix if needed
base_code = code
suffix = 1
while Department.objects.filter(hospital_id=hospital_id, code=code).exists():
code = f"{base_code[:17]}_{suffix}"
suffix += 1
if not dry_run:
with transaction.atomic():
new_dept, was_created = Department.objects.get_or_create(
hospital_id=hospital_id,
name__iexact=dept_name,
defaults={
'name': dept_name,
'name_ar': dept_name, # Use same name for Arabic initially
'code': code,
'status': 'active',
}
)
# If it already existed but wasn't matched, update staff
if not was_created:
# Department existed but with different case - update name
new_dept.name = dept_name
new_dept.save(update_fields=['name'])
else:
# Dry run - simulate creation
new_dept = type('Department', (), {'name': dept_name, 'id': 'NEW'})()
was_created = True
if was_created or True: # Always count for dry-run
created_departments += 1
created_dept_cache[cache_key] = new_dept
created_count += 1
if not dry_run:
staff.department = new_dept
staff.save(update_fields=['department'])
action = "CREATED" if was_created else "LINKED"
self.stdout.write(f" + {action}: {staff.get_full_name()} -> {new_dept.name}")
continue
# No match found and not creating
no_match += 1
unmatched_departments.add((staff.hospital.name, dept_name))
self.stdout.write(f" ✗ NO MATCH: {staff.get_full_name()} '{dept_name}'")
# Summary
self.stdout.write("\n" + "=" * 60)
self.stdout.write("MIGRATION SUMMARY")
self.stdout.write("=" * 60)
self.stdout.write(f"Total staff processed: {total_count}")
self.stdout.write(self.style.SUCCESS(f" Exact matches: {exact_matches}"))
if fuzzy_match:
self.stdout.write(self.style.WARNING(f" Fuzzy matches: {fuzzy_matches}"))
self.stdout.write(self.style.WARNING(f" Multiple matches (skipped): {multiple_matches}"))
if create_missing:
self.stdout.write(self.style.SUCCESS(f" Departments created: {created_departments}"))
self.stdout.write(self.style.SUCCESS(f" Staff linked to new departments: {created_count}"))
self.stdout.write(self.style.ERROR(f" No match found: {no_match}"))
if dry_run:
self.stdout.write(self.style.WARNING("\nDRY RUN - No changes were made"))
self.stdout.write("Run without --dry-run to apply changes")
else:
self.stdout.write(self.style.SUCCESS(f"\nSuccessfully updated {exact_matches + fuzzy_matches} staff records"))
# Report unmatched departments
if unmatched_departments:
self.stdout.write("\n" + "=" * 60)
self.stdout.write("UNMATCHED DEPARTMENTS (need manual creation or name fix)")
self.stdout.write("=" * 60)
for hospital_name, dept_name in sorted(unmatched_departments):
self.stdout.write(f" {hospital_name}: '{dept_name}'")
# Report ambiguous matches
if ambiguous_matches:
self.stdout.write("\n" + "=" * 60)
self.stdout.write("AMBIGUOUS MATCHES (multiple departments matched)")
self.stdout.write("=" * 60)
for staff_id, info in ambiguous_matches.items():
self.stdout.write(f" {info['staff_name']} ('{info['department_name']}')")
for match in info['matches']:
self.stdout.write(f" - {match}")
# Suggest creating missing departments
if unmatched_departments and not dry_run and not create_missing:
self.stdout.write("\n" + "=" * 60)
self.stdout.write("TIP: Run with --create-missing to auto-create departments")
self.stdout.write(" Or create them manually in Django Admin")
def handle_force_create(self, dry_run, hospital_code, no_confirm):
"""Delete all existing departments and recreate from department_name field."""
self.stdout.write(self.style.WARNING("\n" + "=" * 60))
self.stdout.write(self.style.WARNING("FORCE-CREATE MODE"))
self.stdout.write(self.style.WARNING("=" * 60))
self.stdout.write(self.style.WARNING("This will DELETE ALL EXISTING DEPARTMENTS and recreate them."))
self.stdout.write(self.style.WARNING("This is a DESTRUCTIVE operation!\n"))
# Build querysets
dept_qs = Department.objects.all()
staff_qs = Staff.objects.select_related('hospital').filter(
department_name__isnull=False
).exclude(
department_name=''
)
if hospital_code:
dept_qs = dept_qs.filter(hospital__code=hospital_code)
staff_qs = staff_qs.filter(hospital__code=hospital_code)
self.stdout.write(f"Filtering by hospital code: {hospital_code}")
# Count what will be affected
dept_count = dept_qs.count()
staff_count = staff_qs.count()
# Get unique department names from staff
unique_dept_names = set()
for staff in staff_qs:
dept_name = staff.department_name.strip()
if dept_name:
unique_dept_names.add((staff.hospital_id, dept_name))
self.stdout.write(f"\nDepartments to be DELETED: {dept_count}")
self.stdout.write(f"Staff records to be updated: {staff_count}")
self.stdout.write(f"New departments to be CREATED: {len(unique_dept_names)}")
if dry_run:
self.stdout.write(self.style.WARNING("\nDRY RUN - No changes will be made"))
self.stdout.write("\nDepartments that would be deleted:")
for dept in dept_qs:
self.stdout.write(f" - {dept.hospital.name}: {dept.name}")
self.stdout.write("\nDepartments that would be created:")
for hospital_id, dept_name in sorted(unique_dept_names):
self.stdout.write(f" + {dept_name}")
return
# Confirmation
if not no_confirm:
self.stdout.write("\n" + "=" * 60)
confirm = input("Are you sure you want to proceed? Type 'yes' to confirm: ")
if confirm.lower() != 'yes':
self.stdout.write(self.style.ERROR("Operation cancelled."))
return
# Execute force-create
self.stdout.write("\nProceeding with force-create...")
with transaction.atomic():
# Step 1: Clear department foreign keys on staff
self.stdout.write("Step 1: Clearing staff.department foreign keys...")
staff_qs.update(department=None)
self.stdout.write(self.style.SUCCESS(f" Cleared {staff_count} staff records"))
# Step 2: Delete all existing departments
self.stdout.write("Step 2: Deleting existing departments...")
deleted_count, _ = dept_qs.delete()
self.stdout.write(self.style.SUCCESS(f" Deleted {deleted_count} departments"))
# Step 3: Create new departments from department_name
self.stdout.write("Step 3: Creating new departments from department_name...")
created_departments = {}
created_count = 0
for staff in staff_qs:
dept_name = staff.department_name.strip()
hospital_id = staff.hospital_id
if not dept_name:
continue
cache_key = (hospital_id, dept_name.lower())
# Check if we already created this department
if cache_key in created_departments:
new_dept = created_departments[cache_key]
else:
# Generate a unique code
code = dept_name.upper().replace(' ', '_').replace('-', '_')[:20]
base_code = code
suffix = 1
# Ensure uniqueness within created departments
while any(d.code == code and d.hospital_id == hospital_id for d in created_departments.values()):
code = f"{base_code[:17]}_{suffix}"
suffix += 1
# Create the department
new_dept = Department.objects.create(
hospital_id=hospital_id,
name=dept_name,
name_ar=dept_name,
code=code,
status='active',
)
created_departments[cache_key] = new_dept
created_count += 1
self.stdout.write(f" + Created: {new_dept.name}")
# Link staff to department
staff.department = new_dept
staff.save(update_fields=['department'])
self.stdout.write(self.style.SUCCESS(f" Created {created_count} unique departments"))
# Summary
self.stdout.write("\n" + "=" * 60)
self.stdout.write(self.style.SUCCESS("FORCE-CREATE COMPLETE"))
self.stdout.write("=" * 60)
self.stdout.write(f"Departments deleted: {deleted_count}")
self.stdout.write(f"Departments created: {created_count}")
self.stdout.write(f"Staff records updated: {staff_count}")