Auto-commit 2026-09-07 18:33: 2 files changed, 241 insertions(+)
This commit is contained in:
parent
f75d683228
commit
816402334b
1
.gitignore
vendored
1
.gitignore
vendored
@ -23,3 +23,4 @@
|
||||
moodle/TOKEN.md
|
||||
TOKEN.md
|
||||
*.token
|
||||
*.mbz
|
||||
|
||||
240
moodle/src/fill_mbz.py
Normal file
240
moodle/src/fill_mbz.py
Normal file
@ -0,0 +1,240 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fill an AISE502 Moodle course backup (.mbz, moodle2 format) with the generated section content.
|
||||
|
||||
python3 src/fill_mbz.py inspect <backup.mbz>
|
||||
lists Moodle release, course format, every section (number, id, name) and the activities in it
|
||||
python3 src/fill_mbz.py fill <backup.mbz> <out.mbz> [--summary]
|
||||
week sections 1..14: section name -> "Week N: ...", the three template text areas (Lernziele /
|
||||
Theorie / Selbststudium) -> blocks 1..3 of week_NN.html; two sections appended for the lecture
|
||||
script and the project (cloned from the last week section, one text area each).
|
||||
--summary: write each week's three blocks into the section description instead of text areas.
|
||||
|
||||
Only text fields are changed; ids of new sections/labels are fresh unique numbers; everything else in
|
||||
the archive is copied untouched. The result is validated structurally (all referenced directories exist,
|
||||
XML parses) but a restore in Moodle is the real test.
|
||||
"""
|
||||
import io, pathlib, re, shutil, sys, tarfile, tempfile, zipfile, html
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
HERE = pathlib.Path(__file__).resolve().parent
|
||||
OUT = HERE.parent
|
||||
|
||||
# ----------------------------------------------------------------------------- archive helpers
|
||||
def unpack(mbz, dest):
|
||||
data = open(mbz, "rb").read(4)
|
||||
if data[:2] == b"PK":
|
||||
with zipfile.ZipFile(mbz) as z: z.extractall(dest); return "zip"
|
||||
with tarfile.open(mbz, "r:gz") as t: t.extractall(dest); return "tgz"
|
||||
|
||||
def pack(src, out, kind):
|
||||
src = pathlib.Path(src)
|
||||
if kind == "zip":
|
||||
with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as z:
|
||||
for p in sorted(src.rglob("*")):
|
||||
if p.is_file(): z.write(p, p.relative_to(src).as_posix())
|
||||
else:
|
||||
with tarfile.open(out, "w:gz") as t:
|
||||
for p in sorted(src.rglob("*")):
|
||||
if p.is_file(): t.add(p, p.relative_to(src).as_posix())
|
||||
|
||||
def read(p): return pathlib.Path(p).read_text(encoding="utf-8")
|
||||
def write(p, s): pathlib.Path(p).write_text(s, encoding="utf-8")
|
||||
|
||||
def tag(xml, name):
|
||||
m = re.search(rf"<{name}>(.*?)</{name}>", xml, re.S)
|
||||
return html.unescape(m.group(1)) if m else None
|
||||
|
||||
def set_tag(xml, name, value, count=1):
|
||||
esc = html.escape(value, quote=False)
|
||||
new, n = re.subn(rf"<{name}>.*?</{name}>", lambda m: f"<{name}>{esc}</{name}>", xml, count=count, flags=re.S)
|
||||
if n == 0: # empty tag form <name/> or <name></name>
|
||||
new, n = re.subn(rf"<{name}\s*/>", f"<{name}>{esc}</{name}>", xml, count=count)
|
||||
assert n, f"tag {name} not found"
|
||||
return new
|
||||
|
||||
# ----------------------------------------------------------------------------- model
|
||||
class Backup:
|
||||
def __init__(self, root):
|
||||
self.root = pathlib.Path(root)
|
||||
self.mb = read(self.root / "moodle_backup.xml")
|
||||
self.release = tag(self.mb, "moodle_release")
|
||||
self.format = tag(self.mb, "format") # course format e.g. topics
|
||||
# sections: from moodle_backup.xml contents
|
||||
self.sections = []
|
||||
for m in re.finditer(r"<section>\s*<sectionid>(\d+)</sectionid>\s*<title>(.*?)</title>\s*<directory>(.*?)</directory>\s*</section>", self.mb, re.S):
|
||||
sid, title, d = int(m.group(1)), m.group(2), m.group(3)
|
||||
sx = read(self.root / d / "section.xml")
|
||||
self.sections.append(dict(id=sid, dir=d, number=int(tag(sx, "number")), name=tag(sx, "name") or "",
|
||||
sequence=[int(x) for x in (tag(sx, "sequence") or "").split(",") if x.strip()],
|
||||
visible=tag(sx, "visible")))
|
||||
self.sections.sort(key=lambda s: s["number"])
|
||||
self.activities = {}
|
||||
for m in re.finditer(r"<activity>\s*<moduleid>(\d+)</moduleid>\s*<sectionid>(\d+)</sectionid>\s*<modulename>(.*?)</modulename>\s*<title>(.*?)</title>\s*<directory>(.*?)</directory>\s*</activity>", self.mb, re.S):
|
||||
mid, sid, mod, title, d = int(m.group(1)), int(m.group(2)), m.group(3), html.unescape(m.group(4)), m.group(5)
|
||||
a = dict(moduleid=mid, sectionid=sid, modname=mod, title=title, dir=d)
|
||||
mx = read(self.root / d / "module.xml")
|
||||
a["visible"] = tag(mx, "visible")
|
||||
if mod == "label":
|
||||
lx = read(self.root / d / "label.xml")
|
||||
a["intro"] = tag(lx, "intro") or ""
|
||||
self.activities[mid] = a
|
||||
|
||||
def label_kind(self, a):
|
||||
t = re.sub(r"<[^>]+>", " ", a.get("intro", ""))
|
||||
t = html.unescape(t)
|
||||
if a["visible"] == "0" or "Auftrag Dozierende" in t: return "hidden"
|
||||
if "Lernziele" in t or "Learning objectives" in t: return "objectives"
|
||||
if "Theorie" in t or "Theory" in t: return "theory"
|
||||
if "Selbststudium" in t or "Self-study" in t: return "selfstudy"
|
||||
return "other"
|
||||
|
||||
def inspect(mbz):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
kind = unpack(mbz, tmp)
|
||||
b = Backup(tmp)
|
||||
print(f"archive: {kind} · Moodle {b.release} · course format: {b.format} · {len(b.sections)} sections · {len(b.activities)} activities")
|
||||
for s in b.sections:
|
||||
print(f"\n[{s['number']}] id={s['id']} visible={s['visible']} name={s['name']!r}")
|
||||
for mid in s["sequence"]:
|
||||
a = b.activities.get(mid)
|
||||
if not a: print(f" ? module {mid} not in backup contents"); continue
|
||||
kind_ = b.label_kind(a) if a["modname"] == "label" else "-"
|
||||
print(f" {a['modname']:<10} id={mid:<6} visible={a['visible']} kind={kind_:<10} {a['title'][:70]!r}")
|
||||
|
||||
# ----------------------------------------------------------------------------- filling
|
||||
def blocks_of(week):
|
||||
t = read(OUT / f"week_{week:02d}.html")
|
||||
parts = re.split(r"<!-- BLOCK \d · [^>]*-->\n", t)
|
||||
assert len(parts) == 4, f"week {week}: expected 3 blocks, got {len(parts)-1}"
|
||||
title = re.search(r"Section name\): (.*)", t).group(1).strip()
|
||||
return title, [p.strip() + "\n" for p in parts[1:]]
|
||||
|
||||
def label_name(intro):
|
||||
t = html.unescape(re.sub(r"<[^>]+>", " ", intro)); t = re.sub(r"\s+", " ", t).strip()
|
||||
return t[:60]
|
||||
|
||||
def set_label(b, a, intro_html):
|
||||
p = b.root / a["dir"] / "label.xml"
|
||||
lx = read(p)
|
||||
lx = set_tag(lx, "intro", intro_html)
|
||||
lx = set_tag(lx, "name", label_name(intro_html))
|
||||
lx = set_tag(lx, "introformat", "1")
|
||||
write(p, lx)
|
||||
# title in moodle_backup.xml
|
||||
b.mb = re.sub(rf"(<moduleid>{a['moduleid']}</moduleid>\s*<sectionid>\d+</sectionid>\s*<modulename>label</modulename>\s*<title>).*?(</title>)",
|
||||
lambda m: m.group(1) + html.escape(label_name(intro_html), quote=False) + m.group(2), b.mb, count=1, flags=re.S)
|
||||
|
||||
def set_section_name(b, s, name):
|
||||
p = b.root / s["dir"] / "section.xml"
|
||||
write(p, set_tag(read(p), "name", name))
|
||||
s["name"] = name
|
||||
|
||||
def set_section_summary(b, s, summary_html):
|
||||
p = b.root / s["dir"] / "section.xml"
|
||||
sx = set_tag(read(p), "summary", summary_html)
|
||||
sx = set_tag(sx, "summaryformat", "1")
|
||||
write(p, sx)
|
||||
|
||||
def next_ids(b):
|
||||
"""fresh ids above everything used in the archive"""
|
||||
nums = [int(x) for x in re.findall(r'id="(\d+)"|<id>(\d+)</id>|_(\d+)/|<moduleid>(\d+)|<sectionid>(\d+)|contextid="(\d+)"', b.mb) for x in x if x] if False else []
|
||||
allx = "\n".join(read(p) for p in b.root.rglob("*.xml"))
|
||||
nums = [int(n) for n in re.findall(r"\b(\d{1,9})\b", allx)]
|
||||
base = (max(nums) // 1000 + 1) * 1000
|
||||
return base
|
||||
|
||||
def clone_section(b, template, number, name, label_intros, base):
|
||||
"""clone a week section directory as section `number` with len(label_intros) label activities (cloned from the template's first label)"""
|
||||
tsec = template
|
||||
sid = base; base += 1
|
||||
sdir = f"sections/section_{sid}"
|
||||
shutil.copytree(b.root / tsec["dir"], b.root / sdir)
|
||||
sx = read(b.root / sdir / "section.xml")
|
||||
sx = re.sub(r'<section id="\d+"', f'<section id="{sid}"', sx, count=1)
|
||||
sx = set_tag(sx, "number", str(number)); sx = set_tag(sx, "name", name)
|
||||
sx = set_tag(sx, "summary", "");
|
||||
# template label activity
|
||||
tlabel = next(b.activities[m] for m in tsec["sequence"] if b.activities[m]["modname"] == "label")
|
||||
mids = []
|
||||
for intro in label_intros:
|
||||
mid = base; base += 1; aid = base; base += 1; ctx = base; base += 1
|
||||
adir = f"activities/label_{mid}"
|
||||
shutil.copytree(b.root / tlabel["dir"], b.root / adir)
|
||||
mx = read(b.root / adir / "module.xml")
|
||||
mx = re.sub(r'<module id="\d+"', f'<module id="{mid}"', mx, count=1)
|
||||
mx = set_tag(mx, "sectionid", str(sid)); mx = set_tag(mx, "sectionnumber", str(number))
|
||||
mx = set_tag(mx, "visible", "1"); mx = set_tag(mx, "visibleoncoursepage", "1") if "<visibleoncoursepage>" in mx else mx
|
||||
write(b.root / adir / "module.xml", mx)
|
||||
lx = read(b.root / adir / "label.xml")
|
||||
lx = re.sub(r'<activity id="\d+" moduleid="\d+" modulename="label" contextid="\d+"',
|
||||
f'<activity id="{aid}" moduleid="{mid}" modulename="label" contextid="{ctx}"', lx, count=1)
|
||||
lx = re.sub(r'<label id="\d+"', f'<label id="{aid}"', lx, count=1)
|
||||
lx = set_tag(lx, "intro", intro); lx = set_tag(lx, "name", label_name(intro)); lx = set_tag(lx, "introformat", "1")
|
||||
write(b.root / adir / "label.xml", lx)
|
||||
# inforef: drop file references of the template label
|
||||
write(b.root / adir / "inforef.xml", '<?xml version="1.0" encoding="UTF-8"?>\n<inforef>\n</inforef>')
|
||||
# register in moodle_backup.xml: contents/activities + settings
|
||||
entry = (f" <activity>\n <moduleid>{mid}</moduleid>\n <sectionid>{sid}</sectionid>\n"
|
||||
f" <modulename>label</modulename>\n <title>{html.escape(label_name(intro), quote=False)}</title>\n"
|
||||
f" <directory>{adir}</directory>\n </activity>\n")
|
||||
b.mb = b.mb.replace("</activities>", entry + " </activities>", 1)
|
||||
for sname, val in ((f"label_{mid}_included", "1"), (f"label_{mid}_userinfo", "0")):
|
||||
b.mb = b.mb.replace("</settings>", f" <setting>\n <level>activity</level>\n <activity>label_{mid}</activity>\n <name>{sname}</name>\n <value>{val}</value>\n </setting>\n </settings>", 1)
|
||||
mids.append(mid)
|
||||
sx = set_tag(sx, "sequence", ",".join(str(m) for m in mids))
|
||||
write(b.root / sdir / "section.xml", sx)
|
||||
write(b.root / sdir / "inforef.xml", '<?xml version="1.0" encoding="UTF-8"?>\n<inforef>\n</inforef>')
|
||||
entry = (f" <section>\n <sectionid>{sid}</sectionid>\n <title>{number}</title>\n <directory>{sdir}</directory>\n </section>\n")
|
||||
b.mb = b.mb.replace("</sections>", entry + " </sections>", 1)
|
||||
for sname, val in ((f"section_{sid}_included", "1"), (f"section_{sid}_userinfo", "0")):
|
||||
b.mb = b.mb.replace("</settings>", f" <setting>\n <level>section</level>\n <section>section_{sid}</section>\n <name>{sname}</name>\n <value>{val}</value>\n </setting>\n </settings>", 1)
|
||||
return base
|
||||
|
||||
def fill(mbz, out, use_summary=False):
|
||||
tmp = tempfile.mkdtemp()
|
||||
kind = unpack(mbz, tmp)
|
||||
b = Backup(tmp)
|
||||
weeks = {s["number"]: s for s in b.sections if 1 <= s["number"] <= 14}
|
||||
assert len(weeks) == 14, f"expected sections 1..14, found {sorted(weeks)}"
|
||||
report = []
|
||||
for n in range(1, 15):
|
||||
s = weeks[n]; title, blocks = blocks_of(n)
|
||||
set_section_name(b, s, title)
|
||||
if use_summary:
|
||||
set_section_summary(b, s, "\n".join(blocks)); report.append(f"week {n}: name + summary"); continue
|
||||
kinds = {}
|
||||
for mid in s["sequence"]:
|
||||
a = b.activities[mid]
|
||||
if a["modname"] == "label":
|
||||
k = b.label_kind(a)
|
||||
if k in ("objectives", "theory", "selfstudy") and k not in kinds: kinds[k] = a
|
||||
missing = [k for k in ("objectives", "theory", "selfstudy") if k not in kinds]
|
||||
assert not missing, f"week {n}: no template text area for {missing}; use --summary or adjust label_kind()"
|
||||
for k, blk in zip(("objectives", "theory", "selfstudy"), blocks):
|
||||
set_label(b, kinds[k], blk)
|
||||
report.append(f"week {n}: name + 3 text areas")
|
||||
# two extra sections
|
||||
base = next_ids(b)
|
||||
last = max(b.sections, key=lambda s: s["number"])
|
||||
nxt = last["number"] + 1
|
||||
script_html = read(OUT / "lecture_script.html"); project_html = read(OUT / "project_exercise.html")
|
||||
def body(t): return re.sub(r"<!--.*?-->\s*", "", t, count=1, flags=re.S).strip() + "\n"
|
||||
base = clone_section(b, weeks[14], nxt, "📘 Lecture script", [body(script_html)], base)
|
||||
base = clone_section(b, weeks[14], nxt + 1, "🛠️ Project exercise", [body(project_html)], base)
|
||||
report.append(f"sections {nxt} (script) and {nxt+1} (project) appended")
|
||||
write(pathlib.Path(tmp) / "moodle_backup.xml", b.mb)
|
||||
# structural validation
|
||||
b2 = Backup(tmp)
|
||||
for s in b2.sections:
|
||||
for mid in s["sequence"]:
|
||||
assert mid in b2.activities, f"section {s['number']} references unknown module {mid}"
|
||||
for p in pathlib.Path(tmp).rglob("*.xml"):
|
||||
ET.parse(p)
|
||||
pack(tmp, out, kind)
|
||||
shutil.rmtree(tmp)
|
||||
print("\n".join(report)); print(f"wrote {out} ({kind}); Moodle release in backup: {b2.release}; sections now: {[s['number'] for s in b2.sections]}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) >= 3 and sys.argv[1] == "inspect": inspect(sys.argv[2])
|
||||
elif len(sys.argv) >= 4 and sys.argv[1] == "fill": fill(sys.argv[2], sys.argv[3], "--summary" in sys.argv)
|
||||
else: print(__doc__)
|
||||
Loading…
x
Reference in New Issue
Block a user