migration repaire

This commit is contained in:
javad committed 2026-10-08 18:09:12 +03:30
1 parent c8d45dd07c
commit 56d8760613
11 files changed
+492 -5064

No files matched your search

File diff suppressed because it is too large. Load diff
+18
View File
@@ -1,6 +1,7 @@
from utils.admin_base import CRMModelAdmin
from django.contrib import admin
from apps.customer.models import (
MobileConflict,
Customer as CustomerModel,
CustomerNote as CustomerNoteModel,
ShiveAshenaei as ShiveAshenaeiModel,
@@ -12,6 +13,23 @@ from apps.customer.models import (
# Register your models here.
@admin.register(MobileConflict)
class MobileConflictAdmin(CRMModelAdmin):
list_display = ('source_model', 'record_id', 'workspace_id', 'original_mobile', 'canonical_mobile', 'created_at')
list_filter = ('source_model', 'workspace_id')
search_fields = ('=record_id', '=original_mobile', '=canonical_mobile')
readonly_fields = ('source_model', 'record_id', 'workspace_id', 'original_mobile', 'canonical_mobile', 'snapshot', 'created_at')
def has_add_permission(self, request):
return False
def has_change_permission(self, request, obj=None):
return False
def has_delete_permission(self, request, obj=None):
return False
class CustomerNoteInline(admin.TabularInline):
model = CustomerNoteModel
extra = 0
+1
View File
@@ -0,0 +1 @@
@@ -0,0 +1 @@
@@ -0,0 +1,100 @@
"""Read-only diagnostics for the legacy mobile uniqueness migration."""
import json
from collections import defaultdict
from importlib import import_module
from django.core.management.base import BaseCommand, CommandError
from django.db import connections
from apps.cuser.cuser_model import CUser
from apps.customer.models import Customer
canonical_mobile = import_module(
'apps.customer.migrations.0008_customer_mobile_unique'
).canonical_mobile
def _duplicate_groups(model, alias, per_workspace=False):
"""Use the migration's exact comparison, including country-prefix collisions."""
groups = defaultdict(list)
fields = ['pk', 'mobile'] + (['sys_ws_id'] if per_workspace else [])
for row in model.objects.using(alias).order_by('pk').values(*fields).iterator():
mobile = canonical_mobile(row['mobile'])
if mobile is None:
continue
key = (row['sys_ws_id'], mobile) if per_workspace else (None, mobile)
groups[key].append(row['pk'])
return [
{'workspace_id': workspace, 'canonical_mobile': mobile, 'record_ids': ids}
for (workspace, mobile), ids in groups.items() if len(ids) > 1
]
def _related_counts(record, alias):
"""Count direct foreign-key dependents without reading their private contents."""
counts = {}
for relation in record._meta.related_objects:
if relation.one_to_many or relation.one_to_one:
label = f'{relation.related_model._meta.label}.{relation.field.name}'
counts[label] = relation.related_model._default_manager.using(alias).filter(
**{relation.field.attname: record.pk}
).count()
return counts
def _customer_details(ids, alias):
rows = Customer.objects.using(alias).filter(pk__in=ids).select_related(
'sys_ws', 'cuser_role__cuser', 'cuser_role__role_type'
).order_by('pk')
details = []
for row in rows:
role = row.cuser_role
details.append({
'id': row.pk, 'name_family': row.name_family,
'mobile': row.mobile, 'canonical_mobile': canonical_mobile(row.mobile),
'workspace_id': row.sys_ws_id, 'workspace_name': row.sys_ws.name,
'panel_role': None if role is None else {
'id': role.pk, 'user_id': role.cuser_id, 'workspace_id': role.sys_ws_id,
'role': role.role_type.role_name_en, 'mobile': role.mobile,
'user_mobile': role.cuser.mobile if role.cuser_id else None,
'is_active': role.is_active, 'customer_panel': role.customer_panel,
},
'related_counts': _related_counts(row, alias),
})
return details
class Command(BaseCommand):
help = 'Report legacy Customer/CUser mobile conflicts; never modify or delete records.'
# Must work before migrations, without checking unrelated model declarations.
requires_system_checks = []
def add_arguments(self, parser):
parser.add_argument('--database', default='default')
parser.add_argument('--customer-ids', nargs='+', type=int, default=[])
def handle(self, *args, **options):
alias = options['database']
if alias not in connections:
raise CommandError(f'Unknown database alias: {alias}')
customer_groups = _duplicate_groups(Customer, alias, per_workspace=True)
user_groups = _duplicate_groups(CUser, alias)
customer_ids = set(options['customer_ids'])
customer_ids.update(pk for group in customer_groups for pk in group['record_ids'])
user_ids = [pk for group in user_groups for pk in group['record_ids']]
report = {
'read_only': True, 'database': alias,
'duplicate_group_count': len(customer_groups) + len(user_groups),
'customer_duplicate_groups': customer_groups,
'user_duplicate_groups': user_groups,
'customers': _customer_details(customer_ids, alias),
'missing_requested_customer_ids': sorted(customer_ids - set(
Customer.objects.using(alias).filter(pk__in=customer_ids).values_list('pk', flat=True)
)),
'users': [dict(row, canonical_mobile=canonical_mobile(row['mobile'])) for row in
CUser.objects.using(alias).filter(pk__in=user_ids).order_by('pk').values(
'id', 'username', 'name_family', 'mobile', 'is_active'
)],
}
self.stdout.write(json.dumps(report, ensure_ascii=False, indent=2))
@@ -1,4 +1,5 @@
from django.db import migrations, models
from apps.customer.mobile_migration_repair import repair_conflicts
def canonical_mobile(value):
@@ -10,11 +11,13 @@ def canonical_mobile(value):
return value
def normalized_rows(model, alias, per_workspace=False):
def normalized_rows(model, alias, per_workspace=False, skip_ids=None):
seen = {}
updates = []
fields = ('pk', 'mobile', 'sys_ws_id') if per_workspace else ('pk', 'mobile')
for row in model.objects.using(alias).only(*fields).iterator():
if skip_ids and row.pk in skip_ids:
continue
mobile = canonical_mobile(row.mobile)
key = (row.sys_ws_id, mobile) if per_workspace else mobile
if mobile is not None and key in seen:
@@ -35,9 +38,10 @@ def check_and_normalize_mobiles(apps, schema_editor):
customer = apps.get_model('customer', 'Customer')
user = apps.get_model('cuser', 'CUser')
role = apps.get_model('cuser', 'CuserRole')
# Audit both tables before writing. A collision never silently merges people.
# Quarantine legacy conflicts automatically, retaining a persistent audit.
skipped_users = repair_conflicts(apps, schema_editor, canonical_mobile)
customer_updates = normalized_rows(customer, alias, per_workspace=True)
user_updates = normalized_rows(user, alias)
user_updates = normalized_rows(user, alias, skip_ids=skipped_users)
role_updates = []
for row in role.objects.using(alias).only('pk', 'mobile').iterator():
normalized = canonical_mobile(row.mobile)
@@ -47,6 +51,11 @@ def check_and_normalize_mobiles(apps, schema_editor):
customer.objects.using(alias).bulk_update(customer_updates, ['mobile'], batch_size=500)
user.objects.using(alias).bulk_update(user_updates, ['mobile'], batch_size=500)
role.objects.using(alias).bulk_update(role_updates, ['mobile'], batch_size=500)
if schema_editor.connection.vendor == 'postgresql':
# Drain deferred FK checks from panel detachment before AddConstraint
# alters the same table in this atomic migration.
with schema_editor.connection.cursor() as cursor:
cursor.execute('SET CONSTRAINTS ALL IMMEDIATE')
class Migration(migrations.Migration):
@@ -0,0 +1,37 @@
from django.db import migrations, models
from django.utils import timezone
class CreateAuditModel(migrations.CreateModel):
"""0008 can create this audit table before repairing legacy data.
Existing installations that already applied 0008 create it here instead.
Keep audit history on rollback; it is evidence of irreversible data repairs.
"""
def database_forwards(self, app_label, schema_editor, from_state, to_state):
model = to_state.apps.get_model(app_label, self.name)
if model._meta.db_table not in schema_editor.connection.introspection.table_names():
super().database_forwards(app_label, schema_editor, from_state, to_state)
def database_backwards(self, app_label, schema_editor, from_state, to_state):
pass
class Migration(migrations.Migration):
dependencies = [('customer', '0008_customer_mobile_unique')]
operations = [CreateAuditModel(
name='MobileConflict',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('source_model', models.CharField(max_length=100)),
('record_id', models.PositiveBigIntegerField()),
('workspace_id', models.PositiveBigIntegerField(null=True, blank=True)),
('original_mobile', models.PositiveBigIntegerField(null=True, blank=True)),
('canonical_mobile', models.PositiveBigIntegerField()),
('snapshot', models.JSONField()),
('created_at', models.DateTimeField(default=timezone.now, editable=False)),
],
options={'ordering': ['-created_at', '-pk'],
'unique_together': (('source_model', 'record_id'),),
'verbose_name': 'تعارض شماره قدیمی', 'verbose_name_plural': 'تعارض‌های شماره قدیمی'},
)]
+88
View File
@@ -0,0 +1,88 @@
"""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
+20
View File
@@ -4,9 +4,29 @@ from apps.workspace.models import City as CityModel, Workspace as WorkspaceModel
from apps.cuser.models import CuserRole as CuserRoleModel
from django.core.exceptions import ValidationError
from utils.validations.mobile import normalize_mobile
from django.utils import timezone
# Create your models here.
class MobileConflict(models.Model):
source_model = models.CharField(max_length=100)
record_id = models.PositiveBigIntegerField()
workspace_id = models.PositiveBigIntegerField(null=True, blank=True)
original_mobile = models.PositiveBigIntegerField(null=True, blank=True)
canonical_mobile = models.PositiveBigIntegerField()
snapshot = models.JSONField()
created_at = models.DateTimeField(default=timezone.now, editable=False)
class Meta:
ordering = ['-created_at', '-pk']
unique_together = (('source_model', 'record_id'),)
verbose_name = 'تعارض شماره قدیمی'
verbose_name_plural = 'تعارض‌های شماره قدیمی'
def __str__(self):
return f'{self.source_model} #{self.record_id}: {self.original_mobile}'
class ShiveAshenaei(BaseModel):
name = models.CharField(max_length=15, null=False, blank=False)
sys_ws = models.ForeignKey(WorkspaceModel, on_delete=models.CASCADE, null=False, blank=False)
+205
View File
@@ -0,0 +1,205 @@
"""Legacy deployment diagnostics must expose conflicts without changing data."""
import json
from importlib import import_module
from io import StringIO
from unittest.mock import MagicMock, Mock, patch
from django.apps import apps
from django.contrib import admin
from django.core.management import call_command
from django.core.management.base import CommandError
from django.db import connection, transaction
from django.db.migrations.executor import MigrationExecutor
from django.test import TransactionTestCase
from django.test.utils import CaptureQueriesContext
from apps.cuser.cuser_model import CUser
from apps.customer.models import Customer, CustomerNote, MobileConflict
from apps.jwt_auth.test_regressions import RegressionFixtures
from apps.workspace.models import Workspace
from apps.shop.models import Order
def audit(*args):
output = StringIO()
call_command('audit_mobile_duplicates', *args, stdout=output)
return json.loads(output.getvalue())
def repair():
migration = import_module('apps.customer.migrations.0008_customer_mobile_unique')
migration.check_and_normalize_mobiles(apps, Mock(connection=connection))
class MobileAuditTests(RegressionFixtures):
def test_country_prefix_conflict_reports_all_members_and_dependencies_read_only(self):
other = Customer.objects.create(name_family='Legacy', mobile=989123456789, sys_ws=self.workspace)
note = CustomerNote.objects.create(customer=other, cuser_role=self.role,
desc='Keep this note', sys_ws=self.workspace)
before = list(Customer.objects.order_by('pk').values())
with CaptureQueriesContext(connection) as queries:
report = audit()
self.assertEqual(report['customer_duplicate_groups'], [{
'workspace_id': self.workspace.pk, 'canonical_mobile': self.customer.mobile,
'record_ids': [self.customer.pk, other.pk],
}])
self.assertEqual(report['duplicate_group_count'], 1)
self.assertEqual(report['customers'][1]['related_counts']['customer.CustomerNote.customer'], 1)
self.assertEqual(report['customers'][0]['panel_role']['user_id'], self.user.pk)
self.assertEqual(before, list(Customer.objects.order_by('pk').values()))
self.assertTrue(CustomerNote.objects.filter(pk=note.pk).exists())
writes = [query['sql'] for query in queries if query['sql'].lstrip().split()[0].upper()
in {'INSERT', 'UPDATE', 'DELETE', 'ALTER', 'DROP', 'CREATE'}]
self.assertEqual(writes, [])
def test_cross_workspace_numbers_and_multiple_nulls_are_allowed(self):
Customer.objects.create(name_family='Other tenant', mobile=989123456789, sys_ws=self.other_workspace)
Customer.objects.create(name_family='No number 1', mobile=None, sys_ws=self.workspace)
Customer.objects.create(name_family='No number 2', mobile=None, sys_ws=self.workspace)
self.assertEqual(audit()['duplicate_group_count'], 0)
def test_user_country_prefix_conflict_is_global_and_excludes_secrets(self):
user = CUser.objects.create(username='legacy', name_family='Legacy', mobile=989123456789)
report = audit()
self.assertEqual(report['user_duplicate_groups'][0]['record_ids'], [self.user.pk, user.pk])
self.assertEqual(report['duplicate_group_count'], 1)
self.assertNotIn('password', report['users'][0])
user.refresh_from_db()
self.assertEqual(user.mobile, 989123456789)
def test_requested_customer_details_and_missing_ids_without_duplicates(self):
report = audit('--customer-ids', str(self.customer.pk), '999999')
self.assertEqual(report['customers'][0]['id'], self.customer.pk)
self.assertEqual(report['missing_requested_customer_ids'], [999999])
self.assertEqual(report['duplicate_group_count'], 0)
def test_invalid_database_has_clear_error(self):
with self.assertRaisesMessage(CommandError, 'Unknown database alias'):
audit('--database', 'missing')
def test_automatic_repair_preserves_notes_orders_audit_and_blocks_old_panel_session(self):
tokens = self.use_role()
other = Customer.objects.create(name_family='Legacy', mobile=989123456789, sys_ws=self.workspace)
note = CustomerNote.objects.create(customer=self.customer, cuser_role=self.role,
desc='Keep this note', sys_ws=self.workspace)
order = Order.objects.create(customer=other, total_price=100, factor_type=1, sys_ws=self.workspace)
repair()
self.assertTrue(CustomerNote.objects.filter(pk=note.pk, customer=self.customer).exists())
self.assertTrue(Order.objects.filter(pk=order.pk, customer=other).exists())
entries = MobileConflict.objects.filter(source_model='customer.Customer')
self.assertEqual(entries.count(), 2)
archived = entries.get(record_id=self.customer.pk)
self.assertEqual(archived.snapshot['cuser_role_id'], self.role.pk)
self.assertTrue(archived.snapshot['roles'][0]['customer_panel'])
self.assertEqual(self.client.get('/cuser/info/').status_code, 401)
self.assertEqual(self.post('token/refresh/', {'refresh': tokens['refresh']}).status_code, 401)
repair()
self.assertEqual(entries.count(), 2)
self.assertEqual(entries.get(record_id=self.customer.pk).snapshot, archived.snapshot)
def test_account_collision_disables_all_ambiguous_accounts_without_changing_their_numbers(self):
tokens = self.use_role()
other = CUser.objects.create(username='legacy', mobile=989123456789, is_active=True)
repair()
self.user.refresh_from_db()
other.refresh_from_db()
self.role.refresh_from_db()
self.operator.refresh_from_db()
self.assertFalse(self.user.is_active)
self.assertFalse(other.is_active)
self.assertFalse(self.role.is_active)
self.assertFalse(self.operator.is_active)
self.assertEqual(self.user.mobile, 9123456789)
self.assertEqual(other.mobile, 989123456789)
self.assertEqual(MobileConflict.objects.filter(source_model='cuser.CUser').count(), 2)
self.assertEqual(self.client.get('/cuser/info/').status_code, 401)
self.assertEqual(self.post('token/refresh/', {'refresh': tokens['refresh']}).status_code, 401)
before = list(MobileConflict.objects.order_by('pk').values())
repair()
self.assertEqual(before, list(MobileConflict.objects.order_by('pk').values()))
def test_failed_archival_rolls_back_entire_repair(self):
other = Customer.objects.create(name_family='Legacy', mobile=989123456789, sys_ws=self.workspace)
Customer.objects.create(name_family='Second group', mobile=9333333333, sys_ws=self.workspace)
Customer.objects.create(name_family='Second group legacy', mobile=989333333333, sys_ws=self.workspace)
module = import_module('apps.customer.mobile_migration_repair')
archive = module._archive
calls = 0
def failing_archive(*args, **kwargs):
nonlocal calls
calls += 1
if calls == 3:
raise RuntimeError('Simulated audit storage failure')
return archive(*args, **kwargs)
with self.assertRaisesMessage(RuntimeError, 'Simulated audit storage failure'):
with transaction.atomic(), patch.object(module, '_archive', side_effect=failing_archive):
repair()
self.customer.refresh_from_db()
other.refresh_from_db()
self.assertEqual(self.customer.mobile, 9123456789)
self.assertEqual(other.mobile, 989123456789)
self.assertEqual(MobileConflict.objects.count(), 0)
def test_admin_audit_history_cannot_be_added_changed_or_deleted(self):
model_admin = admin.site._registry[MobileConflict]
request = Mock(user=Mock(is_superuser=True, is_active=True))
self.assertFalse(model_admin.has_add_permission(request))
self.assertFalse(model_admin.has_change_permission(request))
self.assertFalse(model_admin.has_delete_permission(request))
def test_postgres_migration_locks_legacy_writers_and_drains_deferred_checks(self):
cursor = MagicMock()
postgres = Mock(alias='default', vendor='postgresql', ops=connection.ops)
postgres.cursor.return_value.__enter__ = Mock(return_value=cursor)
postgres.cursor.return_value.__exit__ = Mock(return_value=False)
migration = import_module('apps.customer.migrations.0008_customer_mobile_unique')
migration.check_and_normalize_mobiles(apps, Mock(connection=postgres))
statements = [call.args[0] for call in cursor.execute.call_args_list]
self.assertTrue(statements[0].startswith('LOCK TABLE '))
self.assertIn('SHARE ROW EXCLUSIVE MODE', statements[0])
self.assertEqual(statements[-1], 'SET CONSTRAINTS ALL IMMEDIATE')
class LegacyMobileAuditTests(TransactionTestCase):
def test_exact_duplicates_before_migration_are_automatically_quarantined(self):
executor = MigrationExecutor(connection)
targets = executor.loader.graph.leaf_nodes()
executor.migrate([('customer', '0007_alter_customer_name_family')])
try:
# Real legacy databases do not have the new audit table yet.
with connection.schema_editor() as editor:
editor.delete_model(MobileConflict)
workspace = Workspace.objects.create(name='Legacy workspace', username='legacy-workspace')
first = Customer.objects.create(name_family='First', mobile=9123456789, sys_ws=workspace)
second = Customer.objects.create(name_family='Second', mobile=9123456789, sys_ws=workspace)
report = audit('--customer-ids', str(first.pk), str(second.pk))
self.assertEqual(report['customer_duplicate_groups'][0]['record_ids'], [first.pk, second.pk])
self.assertEqual(Customer.objects.filter(mobile=9123456789).count(), 2)
MigrationExecutor(connection).migrate(targets)
self.assertEqual(Customer.objects.filter(pk__in=[first.pk, second.pk]).count(), 2)
self.assertEqual(Customer.objects.filter(pk__in=[first.pk, second.pk], mobile=None).count(), 2)
self.assertEqual(MobileConflict.objects.filter(source_model='customer.Customer').count(), 2)
self.assertEqual(MobileConflict.objects.get(record_id=first.pk).original_mobile, 9123456789)
self.assertEqual(audit()['duplicate_group_count'], 0)
finally:
# Test data only: restore the normal schema for subsequent tests.
Customer.objects.all().delete()
MigrationExecutor(connection).migrate(targets)
def test_already_applied_0008_installation_creates_missing_audit_table(self):
executor = MigrationExecutor(connection)
targets = executor.loader.graph.leaf_nodes()
executor.migrate([('customer', '0008_customer_mobile_unique')])
try:
# Simulate an installation that applied the original 0008 before
# this automatic repair feature existed.
with connection.schema_editor() as editor:
editor.delete_model(MobileConflict)
self.assertNotIn(MobileConflict._meta.db_table, connection.introspection.table_names())
MigrationExecutor(connection).migrate(targets)
self.assertIn(MobileConflict._meta.db_table, connection.introspection.table_names())
self.assertEqual(MobileConflict.objects.count(), 0)
finally:
MigrationExecutor(connection).migrate(targets)
+10 -4
View File
@@ -272,16 +272,22 @@ class AuthRegressionTests(RegressionFixtures):
self.assertFalse(self.role.customer_panel)
self.assertEqual(self.post('token/refresh/', {'refresh': tokens['refresh']}).status_code, 401)
def test_duplicate_migration_audits_before_changing_any_data(self):
def test_duplicate_migration_quarantines_without_deleting_customers(self):
import importlib
from django.apps import apps
from django.db import connection
duplicate = Customer.objects.create(name_family='Legacy', mobile=989123456789, sys_ws=self.workspace)
migration = importlib.import_module('apps.customer.migrations.0008_customer_mobile_unique')
with self.assertRaises(RuntimeError):
migration.check_and_normalize_mobiles(apps, Mock(connection=connection))
migration.check_and_normalize_mobiles(apps, Mock(connection=connection))
duplicate.refresh_from_db()
self.assertEqual(duplicate.mobile, 989123456789)
self.customer.refresh_from_db()
self.role.refresh_from_db()
self.assertIsNone(duplicate.mobile)
self.assertIsNone(self.customer.mobile)
self.assertIsNone(self.customer.cuser_role_id)
self.assertFalse(self.role.customer_panel)
from apps.customer.models import MobileConflict
self.assertEqual(MobileConflict.objects.get(record_id=duplicate.pk).original_mobile, 989123456789)
def test_migration_normalizes_legacy_country_prefix(self):
import importlib