fix mobile and names
This commit is contained in:
1 parent
e3a6d1f9d4
commit
7e5c83bea2
12 files changed
+82792
-6
No files matched your search
@@ -0,0 +1,484 @@
|
||||
"""افزودن قطعیِ پیشوند «آقای/خانم» به ابتدای نام مشتریان، بر پایهٔ فایل نامهای ایرانی.
|
||||
|
||||
سیاستها (کاملاً محافظهکارانه):
|
||||
|
||||
* **پیشفرض بدون نوشتن است** (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_ID>
|
||||
|
||||
شروع عمدی 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"))
|
||||
|
||||
Reference in new issue
Block a user