""" 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}")