diff --git a/sbas/management/__init__.py b/sbas/management/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/sbas/management/commands/__init__.py b/sbas/management/commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/sbas/management/commands/normalize_question_html.py b/sbas/management/commands/normalize_question_html.py new file mode 100644 index 00000000..fb424991 --- /dev/null +++ b/sbas/management/commands/normalize_question_html.py @@ -0,0 +1,100 @@ +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)'}.")