Add management command to normalize Question HTML fields by unwrapping single <p> tags
This commit is contained in:
@@ -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*<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)'}.")
|
||||
Reference in New Issue
Block a user