Files
penracourses/sbas/management/commands/normalize_question_html.py
T

101 lines
3.8 KiB
Python

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*<p\b[^>]*>(.*)</p>\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 <p>...</p>. 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)'}.")