"""Frozen, historical-model-only repair used by migration 0008.""" from collections import defaultdict from importlib import import_module import logging from django.db.migrations.state import ProjectState def _groups(model, alias, canonical, workspace=False): groups = defaultdict(list) for row in model.objects.using(alias).order_by('pk').iterator(): mobile = canonical(row.mobile) if mobile is not None: groups[(row.sys_ws_id if workspace else None, mobile)].append(row) return {key: rows for key, rows in groups.items() if len(rows) > 1} def _audit_model(schema_editor): # Render the frozen 0009 schema; never depend on future live model fields. operation = import_module('apps.customer.migrations.0009_mobileconflict').Migration.operations[0] state = ProjectState() operation.state_forwards('customer', state) model = state.apps.get_model('customer', 'MobileConflict') if model._meta.db_table not in schema_editor.connection.introspection.table_names(): schema_editor.create_model(model) return model def _archive(audit, alias, row, mobile, snapshot, workspace=None): audit.objects.using(alias).get_or_create( source_model=row._meta.label, record_id=row.pk, defaults={'workspace_id': workspace, 'original_mobile': row.mobile, 'canonical_mobile': mobile, 'snapshot': snapshot}, ) def repair_conflicts(apps, schema_editor, canonical): """Quarantine ambiguous identities; never choose an owner or merge/delete rows.""" alias = schema_editor.connection.alias customer = apps.get_model('customer', 'Customer') user = apps.get_model('cuser', 'CUser') role = apps.get_model('cuser', 'CuserRole') if schema_editor.connection.vendor == 'postgresql': # This migration is atomic. Prevent legacy writers inserting new conflicts # between the audit and the unique constraint, including empty tables. tables = ', '.join(schema_editor.connection.ops.quote_name(model._meta.db_table) for model in (customer, user, role)) with schema_editor.connection.cursor() as cursor: cursor.execute(f'LOCK TABLE {tables} IN SHARE ROW EXCLUSIVE MODE') customer_groups = _groups(customer, alias, canonical, workspace=True) user_groups = _groups(user, alias, canonical) if not customer_groups and not user_groups: return set() audit = _audit_model(schema_editor) for (workspace, mobile), rows in customer_groups.items(): ids = [row.pk for row in rows] for row in rows: roles = list(role.objects.using(alias).filter(pk=row.cuser_role_id).values( 'id', 'cuser_id', 'sys_ws_id', 'mobile', 'is_active', 'customer_panel' )) _archive(audit, alias, row, mobile, { 'reason': 'duplicate_customer_mobile', 'conflicting_record_ids': ids, 'name_family': row.name_family, 'cuser_role_id': row.cuser_role_id, 'roles': roles, 'action': 'clear_mobile_and_detach_panel', }, workspace) role_ids = [row.cuser_role_id for row in rows if row.cuser_role_id is not None] role.objects.using(alias).filter(pk__in=role_ids).update(customer_panel=False) customer.objects.using(alias).filter(pk__in=ids).update(mobile=None, cuser_role_id=None) skipped_users = set() for (_, mobile), rows in user_groups.items(): ids = [row.pk for row in rows] for row in rows: _archive(audit, alias, row, mobile, { 'reason': 'duplicate_account_mobile', 'conflicting_record_ids': ids, 'username': row.username, 'name_family': row.name_family, 'is_active': row.is_active, 'roles': list(role.objects.using(alias).filter(cuser_id=row.pk).values( 'id', 'sys_ws_id', 'mobile', 'is_active', 'customer_panel' )), 'action': 'disable_ambiguous_accounts_preserve_mobile', }) user.objects.using(alias).filter(pk__in=ids).update(is_active=False) role.objects.using(alias).filter(cuser_id__in=ids).update(is_active=False, customer_panel=False) skipped_users.update(ids) logging.getLogger(__name__).warning( 'Legacy mobile conflicts quarantined: %s customers, %s accounts. ' 'No records deleted. Review customer.MobileConflict in Django Admin.', sum(len(rows) for rows in customer_groups.values()), len(skipped_users), ) return skipped_users