from django.core.management.base import BaseCommand from django.db import transaction from sbas.models import Question import re from textwrap import shorten UNWRAP_FIELDS = [ "stem", "feedback", "a_answer", "a_feedback", "b_answer", "b_feedback", "c_answer", "c_feedback", "d_answer", "d_feedback", "e_answer", "e_feedback", ] RE_SINGLE_P = re.compile(r"\s*]*>(.*)

\s*$", flags=re.IGNORECASE | re.DOTALL) def unwrap_single_p(html: str) -> str: if not html or not isinstance(html, str): return html m = RE_SINGLE_P.fullmatch(html) return m.group(1) if m else html class Command(BaseCommand): help = "Normalize Question HTML fields by unwrapping a single root

...

. Safe: supports --dry-run and batching." def add_arguments(self, parser): parser.add_argument("--commit", action="store_true", help="Apply changes; without this flag it's a dry-run.") parser.add_argument("--batch-size", type=int, default=200, help="How many objects to fetch per loop.") parser.add_argument("--limit", type=int, default=0, help="Limit number of questions processed (0 = all).") parser.add_argument("--verbose", action="store_true", help="Show full before/after for each change.") parser.add_argument("--start-id", type=int, default=0, help="Start from Question.id >= start-id (useful for resuming).") def handle(self, *args, **opts): commit = opts["commit"] batch_size = opts["batch_size"] limit = opts["limit"] or None verbose = opts["verbose"] start_id = opts["start_id"] qs = Question.objects.all().order_by("id") if start_id: qs = qs.filter(id__gte=start_id) total = qs.count() self.stdout.write(f"Found {total} questions to inspect (start_id={start_id}).") processed = 0 changed = 0 it = qs.iterator(chunk_size=batch_size) for q in it: processed += 1 if limit and processed > limit: break changed_any = False updates = {} for f in UNWRAP_FIELDS: orig = getattr(q, f, None) new = unwrap_single_p(orig) if new is not None and new != orig: changed_any = True updates[f] = new if changed_any: changed += 1 if verbose: self.stdout.write(f"Question id={q.id} will change fields: {', '.join(updates.keys())}") for f, newval in updates.items(): oldval = getattr(q, f) self.stdout.write(" Field: %s\n BEFORE: %s\n AFTER : %s\n" % ( f, (oldval or "")[:400], (newval or "")[:400], )) else: sample = ", ".join(f"{k}({shorten((getattr(q,k) or ''), width=60)})" for k in updates.keys()) self.stdout.write(f"[DRY] id={q.id} -> {sample}" if not commit else f"[APPLY] id={q.id} -> {', '.join(updates.keys())}") if commit: for k, v in updates.items(): setattr(q, k, v) try: with transaction.atomic(): q.save(update_fields=list(updates.keys())) except Exception as e: self.stderr.write(f"Failed to save Question id={q.id}: {e}") if processed % max(1, batch_size) == 0: self.stdout.write(f"Processed {processed} questions...") self.stdout.write(f"Done. Processed {processed} questions, {changed} would change{' (committed)' if commit else ' (dry-run)'}.")