migration repaire
This commit is contained in:
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
@@ -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
|
||||
|
||||
@@ -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': 'تعارضهای شماره قدیمی'},
|
||||
)]
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user