"""افزودن قطعیِ پیشوند «آقای/خانم» به ابتدای نام مشتریان، بر پایهٔ فایل نام‌های ایرانی. سیاست‌ها (کاملاً محافظه‌کارانه): * **پیش‌فرض بدون نوشتن است** (dry-run). نوشتن فقط با ``--apply`` ممکن است. * برای اجرای واقعی، علاوه بر ``--apply``، تأیید صریح ``--confirm-production`` لازم است. * فقط فیلد ``Customer.name_family`` و آن هم تنها در موارد قطعی تغییر می‌کند. * هیچ جنسیتی ذخیره نمی‌شود؛ هیچ فیلدی به مدل مشتری اضافه نشده است. اجرا: python manage.py fix_customer_names --dry-run python manage.py fix_customer_names --apply --confirm-production ادامهٔ job متوقف‌شده (پیش‌فرض: خودکار آخرین job ناتمام با همان hash ادامه می‌یابد): python manage.py fix_customer_names --apply --confirm-production --resume شروع عمدی job جدید (مثلاً پس از تکمیل job قبلی یا تغییر فایل): python manage.py fix_customer_names --apply --confirm-production --new-job جزئیات ایمنی: * فایل JSON در هر اجرا فقط یک بار خوانده و parse می‌شود و هرگز نوشته نمی‌شود. * تغییرات نام و checkpoint هر batch در یک تراکنش اتمیک روی همان دیتابیس ثبت می‌شوند. * دامنهٔ job با بیشترین pk مشتریان در لحظهٔ آغاز قفل می‌شود؛ مشتریان جدیدتر وارد نمی‌شوند. * قفل بر پایهٔ heartbeat است و پس از crash (پس از ``LOCK_TTL``) قابل بازیابی است. * خطای «database is locked» با backoff نمایی + jitter و تعداد محدود retry می‌شود. * فرمان هیچ migration خودکاری اجرا نمی‌کند. """ import random import time from datetime import timedelta from pathlib import Path from django.conf import settings from django.core.management.base import BaseCommand, CommandError from django.db import DEFAULT_DB_ALIAS, connections, transaction from django.db.models import Max from django.db.utils import OperationalError from django.utils import timezone from admin_panel.models import Customer, CustomerNameFixJob from admin_panel.name_fix import (ALGORITHM_VERSION, DatasetError, decide_prefix, load_name_index) DEFAULT_JSON_FILENAME = "iranianNamesDataset.json" # مدت اعتبار قفل/heartbeat. batchها کوچک‌اند، پس این مقدار سخاوتمندانه است. LOCK_TTL = timedelta(minutes=15) MAX_DB_RETRIES = 4 BACKOFF_BASE_SECONDS = 0.5 # کلیدهای آماری که در ``stats`` مدل job ذخیره و بین اجراها منتقل می‌شوند. STATS_KEYS = ( "scanned", "changed", "unchanged", "single_word", "has_prefix", "empty_invalid", "unknown_name", "ambiguous_gender", "batches", ) # نگاشت «دلیل تصمیم» به «کلید آماری». REASON_TO_STAT = { "male": "changed", "female": "changed", "single_word": "single_word", "has_prefix": "has_prefix", "empty": "empty_invalid", "unknown_name": "unknown_name", "ambiguous_gender": "ambiguous_gender", } class Command(BaseCommand): help = ( "افزودن پیشوند «آقای/خانم» به نام مشتریان بر پایهٔ فایل نام‌های ایرانی؛ " "پیش‌فرض dry-run است و نوشتن نیازمند --apply --confirm-production." ) def add_arguments(self, parser): parser.add_argument( "--json-path", default=str(Path(settings.BASE_DIR) / DEFAULT_JSON_FILENAME), help=f"مسیر فایل دادهٔ نام‌ها (پیش‌فرض: {DEFAULT_JSON_FILENAME} در ریشهٔ پروژه).", ) parser.add_argument( "--batch-size", type=int, default=200, help="تعداد مشتریان در هر batch (پیش‌فرض ۲۰۰).", ) parser.add_argument( "--dry-run", action="store_true", help="صریحاً حالت شبیه‌سازی (پیش‌فرض همین است؛ همراه --apply مجاز نیست).", ) parser.add_argument( "--apply", action="store_true", help="نوشتن واقعی در دیتابیس. بدون این گزینه فقط گزارش (dry-run) تولید می‌شود.", ) parser.add_argument( "--confirm-production", action="store_true", help="تأیید صریح اپراتور برای نوشتن واقعی؛ همراه --apply الزامی است.", ) parser.add_argument( "--resume", type=int, default=None, help="ادامهٔ یک job مشخص با شناسه (pk) آن.", ) parser.add_argument( "--new-job", action="store_true", help="شروع عمدی یک job جدید (حتی اگر job ناتمام یا تکمیل‌شده‌ای وجود داشته باشد).", ) parser.add_argument( "--samples", type=int, default=5, help="تعداد نمونهٔ «قبل/بعد» برای نمایش در گزارش (پیش‌فرض ۵).", ) # ------------------------------------------------------------------ main def handle(self, *args, **options): alias = DEFAULT_DB_ALIAS batch_size = max(1, options["batch_size"]) samples_wanted = max(0, options["samples"]) apply_changes = options["apply"] json_path = self._resolve_json_path(options["json_path"]) self._announce_target(alias) try: name_index = load_name_index(json_path) except DatasetError as exc: raise CommandError(f"فایل داده رد شد و هیچ تغییری اعمال نشد: {exc}") self._print_dataset_report(name_index) if options["dry_run"] and apply_changes: raise CommandError("--dry-run و --apply با هم ناسازگارند.") if not apply_changes: counters, samples = self._run_readonly( name_index, alias, batch_size, samples_wanted) self._print_summary(counters, samples, dry_run=True, job=None) return if not options["confirm_production"]: raise CommandError( "برای نوشتن واقعی، هم --apply و هم --confirm-production لازم است. " "بدون --apply اجرا حالت dry-run است." ) self._ensure_job_table(alias) job, resumed = self._claim_job( alias, name_index, batch_size, options["resume"], options["new_job"]) counters = self._counters_from(job.stats) note = "ادامهٔ job" if resumed else "job جدید" self.stdout.write(self.style.MIGRATE_HEADING( f"job#{job.pk} {note}؛ از pk>{job.last_processed_pk} تا pk<={job.initial_max_pk} " f"(batch={batch_size})" )) try: counters, samples = self._apply( name_index, alias, batch_size, job, counters, samples_wanted) except KeyboardInterrupt: self._finalize(alias, job, counters, CustomerNameFixJob.Status.PAUSED, note="توقف با Ctrl+C؛ قابل ادامه با --resume") self.stdout.write(self.style.WARNING( f"job#{job.pk} متوقف شد؛ برای ادامه: --apply --confirm-production --resume {job.pk}" )) return except Exception as exc: # noqa: BLE001 - هر خطای غیرمنتظره: توقف امن self._finalize(alias, job, counters, CustomerNameFixJob.Status.FAILED, note=f"خطا: {type(exc).__name__}") raise CommandError( f"اجرای job#{job.pk} با خطا متوقف شد و batch جاری rollback شد: {exc}" ) self._finalize(alias, job, counters, CustomerNameFixJob.Status.COMPLETED, note="تکمیل شد") self._print_summary(counters, samples, dry_run=False, job=job) # -------------------------------------------------------------- helpers def _resolve_json_path(self, raw_path): if not raw_path: raise CommandError("مسیر فایل داده مشخص نشده است.") path = Path(raw_path).expanduser() if not path.is_file(): raise CommandError(f"فایل داده پیدا نشد: {path}") return path def _announce_target(self, alias): """مقصد دیتابیس را بدون افشای credential نمایش می‌دهد.""" conf = settings.DATABASES.get(alias, {}) engine = conf.get("ENGINE", "?") name = str(conf.get("NAME", "")) settings_module = getattr(settings, "SETTINGS_MODULE", "") self.stdout.write(f"مقصد دیتابیس: alias={alias} engine={engine}") self.stdout.write(f" NAME: {name}") if "production_settings" in settings_module: self.stdout.write(self.style.WARNING( " توجه: ماژول تنظیمات production فعال است.")) self.stdout.write(" (فقط نام/مسیر دیتابیس چاپ می‌شود؛ credential چاپ نمی‌شود.)") def _print_dataset_report(self, name_index): rep = name_index.report self.stdout.write(self.style.MIGRATE_HEADING("اعتبارسنجی فایل داده")) self.stdout.write(f" مسیر: {name_index.path}") self.stdout.write(f" SHA-256: {name_index.sha256}") self.stdout.write( f" رکورد کل: {rep['records_total']} | کلید قابل‌استفاده: {rep['usable_keys']} | " f"کلید غیرقابل‌استفاده: {rep['unusable_keys']}" ) self.stdout.write( f" ردشده → malformed: {rep['rejected_malformed']} | " f"نام خالی: {rep['rejected_empty_name']} | " f"جنسیت نامعتبر: {rep['rejected_unknown_gender']}" ) self.stdout.write( f" نام تکراری: {rep['duplicate_keys']} " f"(هم‌جنسیت: {rep['duplicate_same_gender']} | متناقض: {rep['duplicate_conflict']})" ) def _ensure_job_table(self, alias): table = CustomerNameFixJob._meta.db_table tables = connections[alias].introspection.table_names() if table not in tables: raise CommandError( f"جدول «{table}» وجود ندارد. ابتدا migration را اعمال کن: " f"python manage.py migrate" ) def _empty_counters(self): return {key: 0 for key in STATS_KEYS} def _counters_from(self, stats): counters = self._empty_counters() if isinstance(stats, dict): for key in STATS_KEYS: value = stats.get(key) if isinstance(value, int): counters[key] = value return counters def _tally(self, counters, decision): counters["scanned"] += 1 counters[REASON_TO_STAT[decision.reason]] += 1 def _finish_counters(self, counters): counters["unchanged"] = counters["scanned"] - counters["changed"] return counters def _collect_sample(self, samples, raw_name, new_name, wanted): if wanted and len(samples) < wanted: samples.append((raw_name, new_name)) # ------------------------------------------------------------ execution def _run_readonly(self, name_index, alias, batch_size, samples_wanted): """اسکن فقط-خواندنی همهٔ مشتریان؛ هیچ نوشتنی (نه نام، نه job) رخ نمی‌دهد.""" counters = self._empty_counters() samples = [] qs = (Customer.objects.using(alias) .only("id", "name_family").order_by("pk")) in_batch = 0 for customer in qs.iterator(chunk_size=batch_size): decision = decide_prefix(customer.name_family, name_index) self._tally(counters, decision) if decision.new_name is not None: self._collect_sample(samples, customer.name_family, decision.new_name, samples_wanted) in_batch += 1 if in_batch >= batch_size: counters["batches"] += 1 in_batch = 0 if in_batch: counters["batches"] += 1 return self._finish_counters(counters), samples def _apply(self, name_index, alias, batch_size, job, totals, samples_wanted): """پردازش batchبه‌batch با checkpoint پایدار بر پایهٔ primary key.""" samples = [] max_pk = job.initial_max_pk or 0 last_pk = job.last_processed_pk or 0 while last_pk < max_pk: rows = list( Customer.objects.using(alias) .filter(pk__gt=last_pk, pk__lte=max_pk) .order_by("pk").only("id", "name_family")[:batch_size] ) if not rows: break batch_counters = self._empty_counters() changes = [] for customer in rows: decision = decide_prefix(customer.name_family, name_index) self._tally(batch_counters, decision) if decision.new_name is not None: # فقط فیلد نام تغییر می‌کند؛ سایر فیلدها دست‌نخورده می‌مانند. changes.append(Customer(pk=customer.pk, name_family=decision.new_name)) self._collect_sample(samples, customer.name_family, decision.new_name, samples_wanted) new_last_pk = rows[-1].pk batch_counters["batches"] = 1 self._commit_batch_with_retry( alias, changes, new_last_pk, batch_counters, job, totals) last_pk = new_last_pk return self._finish_counters(totals), samples def _commit_batch(self, alias, changes, new_last_pk, batch_counters, job, totals): """تغییرات نام + checkpoint + heartbeat را در یک تراکنش اتمیک ثبت می‌کند.""" merged = dict(totals) for key in STATS_KEYS: merged[key] = merged[key] + batch_counters[key] merged = self._finish_counters(merged) now = timezone.now() with transaction.atomic(using=alias): if changes: Customer.objects.using(alias).bulk_update(changes, ["name_family"]) CustomerNameFixJob.objects.using(alias).filter(pk=job.pk).update( last_processed_pk=new_last_pk, stats=merged, heartbeat_at=now, updated_at=now, status=CustomerNameFixJob.Status.RUNNING, ) # فقط پس از commit موفق، آمار در حافظه به‌روز می‌شود (retry آمار را دوباره اضافه نمی‌کند). totals.update(merged) def _commit_batch_with_retry(self, alias, changes, new_last_pk, batch_counters, job, totals): for attempt in range(MAX_DB_RETRIES): try: self._commit_batch(alias, changes, new_last_pk, batch_counters, job, totals) return except OperationalError as exc: locked = "locked" in str(exc).lower() if not locked or attempt == MAX_DB_RETRIES - 1: raise delay = BACKOFF_BASE_SECONDS * (2 ** attempt) delay += random.uniform(0, delay / 2) # jitter self.stderr.write( f"دیتابیس قفل شد؛ تلاش {attempt + 2}/{MAX_DB_RETRIES} پس از {delay:.2f}s…" ) time.sleep(delay) # ------------------------------------------------------------ job / lock def _claim_job(self, alias, name_index, batch_size, resume_id, force_new): """قفل را می‌گیرد و job مناسب (جدید یا قابل‌ادامه) را RETURN می‌کند. خروجی: ``(job, resumed)``. در صورت اجرای هم‌زمان یا تناقض hash، ``CommandError`` پرتاب می‌شود و هیچ تغییری اعمال نمی‌گردد. """ Job = CustomerNameFixJob now = timezone.now() digest = name_index.sha256 active = (Job.objects.using(alias) .filter(status=Job.Status.RUNNING).order_by("-pk").first()) if active is not None: fresh = (active.heartbeat_at is not None and (now - active.heartbeat_at) < LOCK_TTL) if fresh and active.pk != resume_id: raise CommandError( f"job#{active.pk} همین حالا در حال اجراست (heartbeat تازه). " "اجرای هم‌زمان مجاز نیست." ) if not fresh: # قفل کهنه (crash/قطع برق): آزادسازی کنترل‌شده Job.objects.using(alias).filter(pk=active.pk).update( status=Job.Status.FAILED, finished_at=now, updated_at=now, note="قفل کهنه؛ آزادسازی خودکار پس از crash") self.stderr.write( f"job#{active.pk} ناتمام رها شده بود و اکنون به‌عنوان failed آزاد شد.") if resume_id is not None: job = Job.objects.using(alias).filter(pk=resume_id).first() if job is None: raise CommandError(f"job#{resume_id} پیدا نشد.") self._check_hash(job, digest) if job.status == Job.Status.COMPLETED: raise CommandError( f"job#{job.pk} قبلاً تکمیل شده است؛ برای اجرای دوباره --new-job بده.") resumed = True elif force_new: job = self._create_job(alias, digest, batch_size, now) resumed = False else: job = (Job.objects.using(alias) .filter(source_hash=digest, algorithm_version=ALGORITHM_VERSION) .exclude(status=Job.Status.COMPLETED).order_by("-pk").first()) if job is not None: resumed = True else: done = Job.objects.using(alias).filter( source_hash=digest, algorithm_version=ALGORITHM_VERSION, status=Job.Status.COMPLETED).exists() if done: raise CommandError( "روی همین فایل یک job تکمیل‌شده وجود دارد؛ برای اجرای دوبارهٔ " "کنترل‌شده --new-job بده." ) job = self._create_job(alias, digest, batch_size, now) resumed = False Job.objects.using(alias).filter(pk=job.pk).update( status=Job.Status.RUNNING, heartbeat_at=now, updated_at=now) job.refresh_from_db(using=alias) return job, resumed def _check_hash(self, job, digest): if job.source_hash != digest: raise CommandError( f"hash فایل ورودی با job#{job.pk} نمی‌خواند؛ ادامه رد شد و هیچ تغییری " "اعمال نمی‌شود. برای فایل جدید یک job مستقل اجرا کن." ) if job.algorithm_version != ALGORITHM_VERSION: raise CommandError( f"نسخهٔ الگوریتم job#{job.pk} ({job.algorithm_version}) با نسخهٔ فعلی " f"({ALGORITHM_VERSION}) نمی‌خواند؛ ادامه رد شد." ) def _create_job(self, alias, digest, batch_size, now): Job = CustomerNameFixJob initial_max_pk = (Customer.objects.using(alias) .aggregate(m=Max("id"))["m"]) or 0 return Job.objects.using(alias).create( source_hash=digest, algorithm_version=ALGORITHM_VERSION, status=Job.Status.RUNNING, batch_size=batch_size, initial_max_pk=initial_max_pk, last_processed_pk=0, stats=self._finish_counters(self._empty_counters()), heartbeat_at=now, note="", ) def _finalize(self, alias, job, counters, status, note): """وضعیت نهایی/میانی job را با روش اتمیک ثبت می‌کند.""" job.refresh_from_db(using=alias) now = timezone.now() persisted = self._finish_counters(dict(counters)) finished = status in (CustomerNameFixJob.Status.COMPLETED, CustomerNameFixJob.Status.FAILED) finished_at = now if finished else job.finished_at with transaction.atomic(using=alias): CustomerNameFixJob.objects.using(alias).filter(pk=job.pk).update( status=status, stats=persisted, updated_at=now, heartbeat_at=now, finished_at=finished_at, note=note[:255]) job.status = status job.stats = persisted job.finished_at = finished_at # -------------------------------------------------------------- summary def _print_summary(self, counters, samples, dry_run, job): if dry_run: head = "گزارش dry-run — هیچ تغییری نوشته نشد" else: head = f"گزارش اجرای واقعی job#{job.pk} ({job.status})" self.stdout.write(self.style.MIGRATE_HEADING(head)) self.stdout.write(f" مشتریان بررسی‌شده: {counters['scanned']}") self.stdout.write(f" قابل اصلاح / اصلاح‌شده: {counters['changed']}") self.stdout.write(f" بدون تغییر: {counters['unchanged']}") self.stdout.write( f" تک‌کلمه‌ای: {counters['single_word']} | " f"دارای پیشوند: {counters['has_prefix']} | " f"خالی/نامعتبر: {counters['empty_invalid']}" ) self.stdout.write( f" ناشناخته در JSON: {counters['unknown_name']} | " f"جنسیت مبهم/نامعتبر: {counters['ambiguous_gender']}" ) self.stdout.write(f" batchهای تکمیل‌شده: {counters['batches']}") if job is not None: self.stdout.write( f" شناسهٔ job: {job.pk} | شروع: {job.started_at} | پایان: {job.finished_at}" ) if samples: self.stdout.write(" نمونهٔ تغییرات (قبل → بعد):") for before, after in samples: self.stdout.write(f" «{before}» → «{after}»") if dry_run: self.stdout.write(self.style.WARNING( "برای اجرای واقعی: --apply --confirm-production"))