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 utils.admin_base import CRMModelAdmin
from django.contrib import admin from django.contrib import admin
from apps.customer.models import ( from apps.customer.models import (
MobileConflict,
Customer as CustomerModel, Customer as CustomerModel,
CustomerNote as CustomerNoteModel, CustomerNote as CustomerNoteModel,
ShiveAshenaei as ShiveAshenaeiModel, ShiveAshenaei as ShiveAshenaeiModel,
@@ -12,6 +13,23 @@ from apps.customer.models import (
# Register your models here. # 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): class CustomerNoteInline(admin.TabularInline):
model = CustomerNoteModel model = CustomerNoteModel
extra = 0 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 django.db import migrations, models
from apps.customer.mobile_migration_repair import repair_conflicts
def canonical_mobile(value): def canonical_mobile(value):
@@ -10,11 +11,13 @@ def canonical_mobile(value):
return value return value
def normalized_rows(model, alias, per_workspace=False): def normalized_rows(model, alias, per_workspace=False, skip_ids=None):
seen = {} seen = {}
updates = [] updates = []
fields = ('pk', 'mobile', 'sys_ws_id') if per_workspace else ('pk', 'mobile') fields = ('pk', 'mobile', 'sys_ws_id') if per_workspace else ('pk', 'mobile')
for row in model.objects.using(alias).only(*fields).iterator(): 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) mobile = canonical_mobile(row.mobile)
key = (row.sys_ws_id, mobile) if per_workspace else mobile key = (row.sys_ws_id, mobile) if per_workspace else mobile
if mobile is not None and key in seen: 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') customer = apps.get_model('customer', 'Customer')
user = apps.get_model('cuser', 'CUser') user = apps.get_model('cuser', 'CUser')
role = apps.get_model('cuser', 'CuserRole') 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) 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 = [] role_updates = []
for row in role.objects.using(alias).only('pk', 'mobile').iterator(): for row in role.objects.using(alias).only('pk', 'mobile').iterator():
normalized = canonical_mobile(row.mobile) 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) customer.objects.using(alias).bulk_update(customer_updates, ['mobile'], batch_size=500)
user.objects.using(alias).bulk_update(user_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) 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): 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 apps.cuser.models import CuserRole as CuserRoleModel
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from utils.validations.mobile import normalize_mobile from utils.validations.mobile import normalize_mobile
from django.utils import timezone
# Create your models here. # 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): class ShiveAshenaei(BaseModel):
name = models.CharField(max_length=15, null=False, blank=False) name = models.CharField(max_length=15, null=False, blank=False)
sys_ws = models.ForeignKey(WorkspaceModel, on_delete=models.CASCADE, 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.assertFalse(self.role.customer_panel)
self.assertEqual(self.post('token/refresh/', {'refresh': tokens['refresh']}).status_code, 401) 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 import importlib
from django.apps import apps from django.apps import apps
from django.db import connection from django.db import connection
duplicate = Customer.objects.create(name_family='Legacy', mobile=989123456789, sys_ws=self.workspace) duplicate = Customer.objects.create(name_family='Legacy', mobile=989123456789, sys_ws=self.workspace)
migration = importlib.import_module('apps.customer.migrations.0008_customer_mobile_unique') 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() 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): def test_migration_normalizes_legacy_country_prefix(self):
import importlib import importlib