sahil-12kumar/IL_CMS_Tools
1
1r"""2IL CMS Tagger — Excel template generator3----------------------------------------4Standalone tool. Opens the IL CMS Create/Edit Question form with Playwright5(reusing a saved CMS session) and writes a ready-to-fill tagging template6(tagging_template.xlsx) with one column for EVERY field on the form, across all7three tabs:8 9 Learning Outcomes : Subject, Chapter, Topic, Subtopic, Minitopic, Micro topic10 Exam Details : TOC, Grade11 Other Details : Objective, Difficulty Level, Bloom Taxonomy, Relevance,12 Concept Level, Source, Syllabus, Author, Tags,13 Exam, Year, Month, Date14 15For each flat-list dropdown the live options are scraped and turned into an16in-Excel dropdown (data validation). Cascade dropdowns (Chapter / Topic /17Subtopic / Minitopic / Micro topic — options depend on the prior pick), plus18multi-select / date / free-text fields (Author, Tags, Date), are left as plain19columns so you can type freely.20 21Usage22 python make_template.py # auto-pick newest saved session23 python make_template.py --qid ILQ-2344120 # open this question's Edit form24 python make_template.py --state path\to\state.json --out my_template.xlsx25 python make_template.py --no-scrape # skip CMS, use built-in defaults26 python make_template.py --headed # watch the browser (default: headed)27 28Why a question id helps: the surest way to reveal the dropdowns is to open an29existing question's "Edit Details" form. Pass any real question id you can see30in the bank with --qid. Without it the script tries a "Create Question" button,31and if that isn't found it still writes the template from the built-in lists.32 33Scraped values always win; the built-in lists only fill fields that couldn't be34read (e.g. a tab that failed to open). Re-run whenever the CMS taxonomy changes.35"""36 37import argparse38import glob39import os40import sys41import threading42 43from openpyxl import Workbook44from openpyxl.utils import get_column_letter45from openpyxl.worksheet.datavalidation import DataValidation46 47import il_endpoints48 49BASE_DIR = os.path.dirname(os.path.abspath(__file__))50DATA_DIR = os.path.join(os.path.dirname(BASE_DIR), "database")51STATE_FILE = os.path.join(BASE_DIR, "state.json")52STATE_DIR = os.path.join(DATA_DIR, "tagger_sessions")53URL = il_endpoints.QUESTION_BANK_URL54 55# Subject → Chapter → Topic → Sub-Topic taxonomy used to build the cascading56# (dependent) Excel dropdowns. Two offline sources are merged so all five subjects57# have a full cascade:58# * JEE TOC CSV → Physics / Mathematics / Chemistry59# * Biology xlsx → Botany / Zoology (cols SUBJECTNAME/CHAPTERNAME/TOPICNAME/SUBTOPICNAME)60TAXONOMY_CSV = r"C:\Users\admin\Downloads\JEE MAIN TOC (1).csv"61TAX_ENCODING = "cp1252"62BIO_TOC_XLSX = r"C:\Users\admin\Downloads\Biology TOC.xlsx"63SUBJECT_LIST = ["Physics", "Chemistry", "Mathematics", "Botany", "Zoology"]64 65# ── Template layout ───────────────────────────────────────────────────────────66# Every field on the CMS Create-Question form, in tab order, becomes a column.67# mode "scrape" : a flat-list ng-select — we read its options and add an68# in-Excel dropdown (data validation).69# mode "free" : cascade / multi-select / date / free-text — plain column, no70# dropdown (its options depend on prior picks or aren't a list).71# tab : which CMS tab the field lives on (None = no tab switch)72# placeholders : ng-select placeholder texts to try when opening it73# label : the on-screen field label (used as a locator fallback)74# for_attr : label[for=] fallback (works when a value is already selected)75# default : built-in list used only when scraping can't read the field76TRACKING_COLS = ["Status", "Run Details", "Attempts", "Last Worker", "Last Updated"]77 78FIELDS = [79 # ── Learning Outcomes ──────────────────────────────────────────────────────80 {"col": "subject", "label": "Subject", "tab": "Learning Outcomes", "mode": "scrape",81 "placeholders": ["Select Subject"], "for_attr": "Subject",82 "default": ["Physics", "Chemistry", "Biology", "Mathematics"]},83 {"col": "chapter", "label": "Chapter", "tab": "Learning Outcomes", "mode": "free"},84 {"col": "topic", "label": "Topic", "tab": "Learning Outcomes", "mode": "free"},85 {"col": "subtopic", "label": "Subtopic", "tab": "Learning Outcomes", "mode": "free"},86 {"col": "minitopic", "label": "Minitopic", "tab": "Learning Outcomes", "mode": "free"},87 {"col": "micro_topic", "label": "Micro topic", "tab": "Learning Outcomes", "mode": "free"},88 # ── Exam Details ───────────────────────────────────────────────────────────89 {"col": "toc", "label": "TOC", "tab": "Exam Details", "mode": "scrape",90 "placeholders": ["Select TOC"]},91 {"col": "grade", "label": "Grade", "tab": "Exam Details", "mode": "scrape",92 "placeholders": ["Select Grade"], "default": ["Grade 11", "Grade 12"]},93 # ── Other Details ──────────────────────────────────────────────────────────94 {"col": "objective", "label": "Objective", "tab": "Other Details", "mode": "scrape",95 "placeholders": ["Select Objective"], "for_attr": "objective",96 "default": ["Formula Based", "Theory", "Multi-Concept", "Real-Life Example", "Extrapolation"]},97 {"col": "difficulty", "label": "Difficulty Level", "tab": "Other Details", "mode": "scrape",98 "placeholders": ["Select Difficulty Level", "Select Difficulty"], "for_attr": "difficultyLevel",99 "default": ["easy", "moderate", "difficult", "very difficult"]},100 {"col": "bloom_taxonomy", "label": "Bloom Taxonomy", "tab": "Other Details", "mode": "scrape",101 "placeholders": ["Select Bloom Taxonomy", "Select Bloom"], "for_attr": "bloomTaxonomy",102 "default": ["Knowledge", "Understanding (Comprehension)", "Application",103 "Analysis (compare, contrast, categorize, identify)"]},104 {"col": "relevance", "label": "Relevence", "tab": "Other Details", "mode": "scrape",105 "placeholders": ["Select Relevence", "Select Relevance"], "for_attr": "relevence", # CMS typo106 "default": ["Relevant to NEET"]},107 {"col": "concept_level", "label": "Concept Level", "tab": "Other Details", "mode": "scrape",108 "placeholders": ["Select Concept Level"]},109 {"col": "source", "label": "Source", "tab": "Other Details", "mode": "scrape",110 "placeholders": ["Select Source"]},111 {"col": "syllabus", "label": "Syllabus", "tab": "Other Details", "mode": "scrape",112 "placeholders": ["Select Syllabus"]},113 {"col": "author", "label": "Author", "tab": "Other Details", "mode": "free"},114 {"col": "tags", "label": "Tags", "tab": "Other Details", "mode": "free"},115 {"col": "exam", "label": "Exam", "tab": "Other Details", "mode": "scrape",116 "placeholders": ["Select Exam"]},117 {"col": "year", "label": "Year", "tab": "Other Details", "mode": "scrape",118 "placeholders": ["Select Year"]},119 {"col": "month", "label": "Month", "tab": "Other Details", "mode": "scrape",120 "placeholders": ["Select Month"],121 "default": ["January", "February", "March", "April", "May", "June", "July",122 "August", "September", "October", "November", "December"]},123 {"col": "date", "label": "Date", "tab": "Other Details", "mode": "free"},124]125 126SCRAPE_FIELDS = [f for f in FIELDS if f["mode"] == "scrape"]127 128# Lists longer than this aren't turned into a dropdown (e.g. huge Author/Tag129# lists) — they'd bloat the file and overwhelm the picker; the column stays free.130MAX_DROPDOWN_OPTIONS = 500131 132COLUMN_ORDER = ["question_id"] + [f["col"] for f in FIELDS] + TRACKING_COLS133 134# Illustrative rows for the sample workbook. The Physics paths are real taxonomy135# entries so the cascade dropdowns show them as valid picks; the Botany row shows136# what a row looks like for a subject that has no offline cascade data yet.137EXAMPLE_ROWS = [138 {"question_id": "ILQ-2344120", "subject": "Physics", "chapter": "Motion in a Straight Line",139 "topic": "Kinematic equations for uniformly accelerated motion", "subtopic": "Motion in Horizontal Plane",140 "grade": "Grade 11", "objective": "Formula Based", "difficulty": "moderate",141 "bloom_taxonomy": "Application", "relevance": "Relevant to NEET", "Status": "unprocessed"},142 {"question_id": "ILQ-2344125", "subject": "Physics", "chapter": "Motion in a Straight Line",143 "topic": "Kinematic equations for uniformly accelerated motion", "subtopic": "Motion under gravity",144 "grade": "Grade 11", "objective": "Multi-Concept", "difficulty": "difficult",145 "bloom_taxonomy": "Application", "relevance": "Relevant to NEET", "Status": "unprocessed"},146 {"question_id": "ILQ-2344127", "subject": "Physics", "chapter": "Laws of Motion",147 "topic": "Newton's second law of motion", "subtopic": "Impulse & its Applications",148 "grade": "Grade 11", "objective": "Formula Based", "difficulty": "moderate",149 "bloom_taxonomy": "Application", "relevance": "Relevant to NEET", "Status": "unprocessed"},150 {"question_id": "ILQ-2344153", "subject": "Physics", "chapter": "Kinetic Theory",151 "topic": "Kinetic theory of an ideal gas", "subtopic": "RMS Speed",152 "grade": "Grade 11", "objective": "Theory", "difficulty": "moderate",153 "bloom_taxonomy": "Understanding (Comprehension)", "relevance": "Relevant to NEET", "Status": "unprocessed"},154 {"question_id": "ILQ-2400001", "subject": "Botany", "chapter": "Cell: The Unit of Life",155 "topic": "Cell theory and cell organelles", "subtopic": "Mitochondria",156 "grade": "Grade 11", "objective": "Theory", "difficulty": "easy",157 "bloom_taxonomy": "Analysis (compare, contrast, categorize, identify)",158 "relevance": "Relevant to NEET", "Status": "unprocessed"},159]160 161 162# ── Session discovery ─────────────────────────────────────────────────────────163 164def pick_state_file(explicit):165 """Resolve which Playwright storage_state to use."""166 if explicit:167 if not os.path.exists(explicit):168 sys.exit(f"State file not found: {explicit}")169 return explicit170 candidates = sorted(glob.glob(os.path.join(STATE_DIR, "state_*.json")),171 key=os.path.getmtime, reverse=True)172 if candidates:173 return candidates[0]174 if os.path.exists(STATE_FILE):175 return STATE_FILE176 return None177 178 179# ── Scraping ──────────────────────────────────────────────────────────────────180 181def scrape_options(state_file, qid, headless):182 """Open the CMS form and return {col: [options]} for every field we could read."""183 from playwright.sync_api import sync_playwright184 from playwright.sync_api import TimeoutError as PWTimeout185 186 QID_SELECTOR = "input[formcontrolname='searchById'], input[placeholder*='Question ID']"187 found = {}188 189 def wait(page, ms=800):190 page.wait_for_timeout(ms)191 192 def first_visible(locator):193 for i in range(locator.count()):194 item = locator.nth(i)195 try:196 if item.is_visible():197 return item198 except Exception:199 continue200 return None201 202 def maybe_click(page, texts, exact=False):203 for text in texts:204 for loc in [205 page.get_by_role("button", name=text, exact=exact),206 page.get_by_text(text, exact=exact),207 page.locator(f"text={text}"),208 page.locator(f"button:has-text('{text}')"),209 ]:210 target = first_visible(loc)211 if target:212 try:213 target.scroll_into_view_if_needed()214 target.click()215 return True216 except Exception:217 continue218 return False219 220 def open_dropdown(page, field):221 """Click a field's arrow so its ng-dropdown-panel opens. Returns True/False."""222 cands = []223 for ph in field.get("placeholders", []):224 cands += [225 page.locator(f"div[role='dialog'] ng-select:has(.ng-placeholder:text-is('{ph}')) .ng-arrow-wrapper"),226 page.locator(f"ng-select:has(.ng-placeholder:text-is('{ph}')) .ng-arrow-wrapper"),227 page.locator(f"ng-select[placeholder='{ph}'] .ng-arrow-wrapper"),228 ]229 if field.get("for_attr"):230 cands.append(page.locator(231 f".form-group:has(label[for='{field['for_attr']}']) .ng-arrow-wrapper"))232 if field.get("label"):233 # Last resort: the ng-select that follows a label carrying this text.234 cands.append(page.locator(235 f"label:has-text('{field['label']}')"236 ).locator("xpath=following::ng-select[1]//*[contains(@class,'ng-arrow-wrapper')]"))237 for cand in cands:238 target = first_visible(cand)239 if target is None:240 continue241 try:242 target.scroll_into_view_if_needed()243 target.click()244 wait(page, 700)245 if page.locator(".ng-dropdown-panel .ng-option").count() > 0:246 return True247 # opened but empty — close and try next candidate248 page.keyboard.press("Escape")249 except Exception:250 try:251 page.keyboard.press("Escape")252 except Exception:253 pass254 return False255 256 def read_open_options(page):257 opts = page.locator(".ng-dropdown-panel .ng-option")258 out, seen = [], set()259 for i in range(opts.count()):260 try:261 txt = opts.nth(i).inner_text().strip()262 except Exception:263 continue264 if not txt or txt.lower() == "no items found":265 continue266 if txt not in seen:267 seen.add(txt)268 out.append(txt)269 try:270 page.keyboard.press("Escape")271 except Exception:272 pass273 wait(page, 300)274 return out275 276 def open_form(page):277 """Reveal the Create/Edit Question form. Returns the page that has the form."""278 # Path A — open an existing question's Edit Details (most reliable).279 if qid:280 try:281 page.wait_for_load_state("networkidle", timeout=15000)282 except Exception:283 pass284 for _ in range(5):285 box = page.locator(QID_SELECTOR)286 if box.count() and box.first.is_visible():287 break288 maybe_click(page, ["Filter"])289 wait(page, 2000)290 box = page.locator(QID_SELECTOR)291 if box.count() and box.first.is_visible():292 try:293 box.first.fill(qid)294 except Exception:295 pass296 maybe_click(page, ["Save", "Apply"])297 wait(page, 3000)298 links = page.locator("a")299 for i in range(links.count()):300 link = links.nth(i)301 try:302 if qid not in link.inner_text():303 continue304 with page.context.expect_page(timeout=3000) as new_info:305 link.click()306 np = new_info.value307 np.wait_for_load_state()308 page = np309 break310 except PWTimeout:311 try:312 link.click()313 page.wait_for_load_state("networkidle", timeout=8000)314 except Exception:315 continue316 except Exception:317 continue318 wait(page, 3000)319 unapprove = first_visible(page.locator("button:has-text('Unapprove')"))320 if unapprove:321 try:322 unapprove.click()323 wait(page, 1000)324 ok = first_visible(page.locator(325 ".modal-content button.btn-success, .modal-content button:has-text('Submit')"))326 if ok:327 ok.click()328 wait(page, 3000)329 except Exception:330 pass331 for _ in range(2):332 if maybe_click(page, ["Edit Details"]):333 break334 wait(page, 2000)335 wait(page, 2500)336 return page337 # Path B — try a Create Question button.338 if maybe_click(page, ["Create Question", "Add Question", "Create", "Add"]):339 wait(page, 2500)340 return page341 342 with sync_playwright() as pw:343 browser = pw.chromium.launch(headless=headless, slow_mo=120)344 context = (browser.new_context(storage_state=state_file)345 if state_file and os.path.exists(state_file)346 else browser.new_context())347 page = context.new_page()348 page.goto(URL)349 wait(page, 3000)350 if "/login" in page.url:351 browser.close()352 raise RuntimeError("CMS session is not logged in (redirected to /login). "353 "Set up / refresh the CMS login first.")354 355 page = open_form(page)356 357 current_tab = None358 for field in SCRAPE_FIELDS:359 tab = field["tab"]360 if tab and tab != current_tab:361 maybe_click(page, [tab])362 wait(page, 900)363 current_tab = tab364 try:365 if open_dropdown(page, field):366 opts = read_open_options(page)367 if opts:368 found[field["col"]] = opts369 print(f" scraped {field['col']:<14} {len(opts)} option(s)")370 else:371 print(f" WARN {field['col']:<14} dropdown opened but no options")372 else:373 print(f" WARN {field['col']:<14} could not open dropdown")374 except Exception as e:375 print(f" WARN {field['col']:<14} {e}")376 377 browser.close()378 return found379 380 381# ── Other-Details options from the CMS /lookups API (no browser) ──────────────382 383# Which Other-Details column is filled from which /lookups code. Values are the384# exact option NAMES the tagger (push_tags) resolves back to UUIDs, so a pick from385# these dropdowns is guaranteed to tag cleanly.386LOOKUP_CODE_FOR_COL = {387 "objective": "OBJECTIVE", "bloom_taxonomy": "TAXONOMY", "relevance": "RELEVANCE",388 "concept_level": "TEST_LEVEL", "source": "SOURCE", "syllabus": "SYLLABUS",389}390 391 392# The lookup options are tenant/token-bound UUIDs, and /tag/prep + /tag/apply call393# lookups_options() back-to-back — fetch once per QB token per process.394_lookups_cache = {}395_lookups_lock = threading.Lock()396 397 398def lookups_options(state_file):399 """Return {col: [option names]} for the lookup-backed Other-Details fields,400 fetched from the QB /lookups API using a saved CMS session (no Playwright).401 Cached per QB token."""402 sys.path.insert(0, os.path.join(BASE_DIR, "ai_tagger"))403 import push_tags as pt404 h = pt.qb_headers_from_state(state_file)405 token = h["authorization"]406 with _lookups_lock:407 if token in _lookups_cache:408 return _lookups_cache[token]409 codes = sorted(set(LOOKUP_CODE_FOR_COL.values()))410 r = pt.SESSION.post(f"{pt.QB}/lookups", json=codes, headers=h, timeout=30)411 r.raise_for_status()412 by_code = {x["lookupcode"]: [v["name"] for v in x.get("lookupvalues", [])]413 for x in r.json().get("data", [])}414 out = {}415 for col, code in LOOKUP_CODE_FOR_COL.items():416 if by_code.get(code):417 out[col] = by_code[code]418 with _lookups_lock:419 _lookups_cache[token] = out420 return out421 422 423# ── live Subject→Chapter→Topic→Subtopic taxonomy from the CMS community API ────424 425# Grade-tree subject ROOT node ids (the "Master TOC" roots the tagger resolves426# against). Same map as push_tags.SUBJECT_ID.427CMS_SUBJECT_ROOT = {"physics": 1, "chemistry": 2, "mathematics": 3,428 "botany": 4, "zoology": 5}429CMS_GRADES = (11, 12)430 431# Exam → subject list mapping (used to filter cascading dropdowns by selected TOC).432# Key matches the pill data-toc attribute sent from the tagger page.433EXAM_SUBJECTS = {434 "JEE Main": ["Physics", "Chemistry", "Mathematics"],435 "JEE Advanced": ["Physics", "Chemistry", "Mathematics"],436 "NEET": ["Physics", "Chemistry", "Botany", "Zoology"],437 "EAPCET": ["Physics", "Chemistry", "Mathematics"],438 "KCET": ["Physics", "Chemistry", "Mathematics"],439}440 441 442def cms_taxonomy(subjects=None, grades=CMS_GRADES, log=print):443 """Build {subject: {chapter: {topic: [subtopics]}}} live from the community444 node API (extract_toc), instead of the offline CSV/xlsx. One bulk call per445 level (children carry parentNodeId, so a whole level resolves at once);446 grades are merged by name. Needs a community-valid token."""447 from collections import OrderedDict448 sys.path.insert(0, os.path.join(BASE_DIR, "ai_tagger"))449 import extract_toc as toc450 try:451 toc._ensure_session()452 # verify the saved token actually passes the community API; if not, log in453 if not toc._token_works_for_community(toc._SESSION.get("token")):454 toc.fetch_token_via_login(log_cb=lambda m: log(f" [community] {m}"))455 except Exception:456 toc.fetch_token_via_login(log_cb=lambda m: log(f" [community] {m}"))457 458 def bulk_children(parent_ids, grade, chunk=200):459 """[(child_id, child_name, parent_id)] for every parent, one call/chunk."""460 ids, out = [int(x) for x in parent_ids], []461 for i in range(0, len(ids), chunk):462 body = {"nodeIds": ids[i:i + chunk], "status": "active",463 "gradeIds": [int(grade)]}464 data = toc._community_get("/node/get_nodes_under_nodes_grades",465 json_body=body, method="POST")466 for n in data.get("data", {}).get("nodes_grades_list", []):467 out.append((n["nodeId"], n["nodeName"], n.get("parentNodeId")))468 return out469 470 subjects = subjects or SUBJECT_LIST471 tree = OrderedDict()472 for s in subjects:473 root = CMS_SUBJECT_ROOT.get(s.lower())474 if not root:475 log(f" {s:<12} no subject root id — skipped")476 continue477 stree = tree.setdefault(s, OrderedDict())478 for g in grades:479 chaps = bulk_children([root], g)480 chap_name = {cid: nm for cid, nm, _ in chaps}481 if not chap_name:482 continue483 tops = bulk_children(list(chap_name), g)484 top_name = {tid: nm for tid, nm, _ in tops}485 top_parent = {tid: p for tid, _, p in tops}486 subs = bulk_children(list(top_name), g) if top_name else []487 for _, cnm, _ in chaps:488 stree.setdefault(cnm, OrderedDict())489 for tid, tnm, cpar in tops:490 cnm = chap_name.get(cpar)491 if cnm:492 stree[cnm].setdefault(tnm, [])493 for _, snm, tpar in subs:494 cnm = chap_name.get(top_parent.get(tpar))495 tnm = top_name.get(tpar)496 if cnm and tnm:497 lst = stree[cnm].setdefault(tnm, [])498 if snm not in lst:499 lst.append(snm)500 log(f" {s:<12} G{g}: {len(chap_name)} chapters, "501 f"{len(top_name)} topics, {len(subs)} subtopics")502 return tree503 504 505# ── Excel writing ─────────────────────────────────────────────────────────────506 507def load_taxonomy(csv_path=TAXONOMY_CSV, encoding=TAX_ENCODING, subjects=SUBJECT_LIST):508 """Read the Subject/Chapter/Topic/Sub-Topic CSV into an ordered nested dict509 {subject: {chapter: {topic: [subtopics]}}}. Only the requested subjects are510 kept; a requested subject with no rows simply stays absent (empty cascade).511 Returns {} if the file is missing."""512 import csv513 from collections import OrderedDict514 if not csv_path or not os.path.exists(csv_path):515 return {}516 517 def cn(s):518 return " ".join(str(s or "").split()).strip()519 520 want = {s.lower(): s for s in subjects}521 tree = OrderedDict()522 with open(csv_path, encoding=encoding) as f:523 for row in csv.DictReader(f):524 subj = want.get(cn(row.get("Subject")).lower())525 if not subj:526 continue527 ch, tp, st = (cn(row.get("Chapter Name")),528 cn(row.get("Topic")), cn(row.get("Sub-Topic")))529 if not (ch and tp and st):530 continue531 chs = tree.setdefault(subj, OrderedDict())532 tps = chs.setdefault(ch, OrderedDict())533 sts = tps.setdefault(tp, [])534 if st not in sts:535 sts.append(st)536 537 _merge_bio_xlsx(tree, BIO_TOC_XLSX, want)538 return tree539 540 541def _merge_bio_xlsx(tree, xlsx_path, want):542 """Merge Botany/Zoology (and any other requested subject) from a Biology TOC543 .xlsx with columns SUBJECTNAME / CHAPTERNAME / TOPICNAME / SUBTOPICNAME into544 the in-place `tree`. `want` is {subject_lower: CanonicalName}. No-op if the545 file is missing."""546 from collections import OrderedDict547 if not xlsx_path or not os.path.exists(xlsx_path):548 return549 import openpyxl550 551 def cn(s):552 return " ".join(str(s or "").split()).strip()553 554 wb = openpyxl.load_workbook(xlsx_path, read_only=True)555 ws = wb.active556 rows = ws.iter_rows(values_only=True)557 header = [cn(h).lower() for h in next(rows)]558 idx = {name: header.index(name) for name in559 ("subjectname", "chaptername", "topicname", "subtopicname")560 if name in header}561 if len(idx) < 4:562 wb.close()563 return564 for r in rows:565 subj = want.get(cn(r[idx["subjectname"]]).lower())566 if not subj:567 continue568 ch, tp, st = (cn(r[idx["chaptername"]]), cn(r[idx["topicname"]]),569 cn(r[idx["subtopicname"]]))570 if not (ch and tp and st):571 continue572 chs = tree.setdefault(subj, OrderedDict())573 tps = chs.setdefault(ch, OrderedDict())574 sts = tps.setdefault(tp, [])575 if st not in sts:576 sts.append(st)577 wb.close()578 579 580def write_cascade(wb, ws, col_index, tree, subjects, rows):581 """Add dependent Subject→Chapter→Topic→Subtopic dropdowns.582 583 A hidden 'taxonomy' sheet holds, for each level, a *key* column and a *value*584 column, laid out so every parent's children sit in a contiguous block:585 586 C: subject D: chapter (one row per chapter)587 F: "subject|chapter" G: topic (one row per topic)588 I: "subject|chapter|topic" J: subtopic (one row per subtopic)589 590 Three workbook-scoped names then return *only* the current row's children via591 OFFSET(MATCH.., COUNTIF..). The relative row reference is written as row 1 so592 that, evaluated in data row r, it reads the parent cell on the same row r.593 No per-item names and no text sanitisation, so any characters are fine.594 """595 from openpyxl.workbook.defined_name import DefinedName596 597 tax = wb.create_sheet("taxonomy")598 tax.sheet_state = "hidden"599 tax["A1"] = "subject"600 tax["C1"] = "chap_key"; tax["D1"] = "chapter"601 tax["F1"] = "topic_key"; tax["G1"] = "topic"602 tax["I1"] = "sub_key"; tax["J1"] = "subtopic"603 604 for i, s in enumerate(subjects, start=2):605 tax.cell(row=i, column=1, value=s)606 607 cr = fr = ir = 2 # next write-row for chapter / topic / subtopic blocks608 counts = {"chapters": 0, "topics": 0, "subtopics": 0}609 for subj in subjects:610 for ch, topics in (tree.get(subj) or {}).items():611 tax.cell(row=cr, column=3, value=subj)612 tax.cell(row=cr, column=4, value=ch); cr += 1; counts["chapters"] += 1613 for tp, subtopics in topics.items():614 tax.cell(row=fr, column=6, value=f"{subj}|{ch}")615 tax.cell(row=fr, column=7, value=tp); fr += 1; counts["topics"] += 1616 for st in subtopics:617 tax.cell(row=ir, column=9, value=f"{subj}|{ch}|{tp}")618 tax.cell(row=ir, column=10, value=st); ir += 1619 counts["subtopics"] += 1620 621 L = {lvl: get_column_letter(col_index[lvl])622 for lvl in ("subject", "chapter", "topic", "subtopic")}623 subj_ref = f"Tagging!${L['subject']}1"624 chap_ref = f"Tagging!${L['chapter']}1"625 top_ref = f"Tagging!${L['topic']}1"626 t_key = f'{subj_ref}&"|"&{chap_ref}'627 s_key = f'{subj_ref}&"|"&{chap_ref}&"|"&{top_ref}'628 629 defs = {630 "ChapterList": (f"OFFSET(taxonomy!$D$2,MATCH({subj_ref},taxonomy!$C:$C,0)-2,0,"631 f"COUNTIF(taxonomy!$C:$C,{subj_ref}),1)"),632 "TopicList": (f"OFFSET(taxonomy!$G$2,MATCH({t_key},taxonomy!$F:$F,0)-2,0,"633 f"COUNTIF(taxonomy!$F:$F,{t_key}),1)"),634 "SubtopicList": (f"OFFSET(taxonomy!$J$2,MATCH({s_key},taxonomy!$I:$I,0)-2,0,"635 f"COUNTIF(taxonomy!$I:$I,{s_key}),1)"),636 }637 for name, formula in defs.items():638 wb.defined_names[name] = DefinedName(name, attr_text=formula)639 640 # Subject: fixed list of the requested subjects (short → safe inline).641 sub_dv = DataValidation(type="list", allow_blank=True, showDropDown=False,642 formula1='"' + ",".join(subjects) + '"')643 ws.add_data_validation(sub_dv)644 c = get_column_letter(col_index["subject"])645 sub_dv.add(f"{c}2:{c}{rows + 1}")646 647 # Chapter / Topic / Subtopic: dependent lists. Errors off, so an empty parent648 # just yields an empty dropdown instead of nagging the user.649 for col, name in (("chapter", "ChapterList"), ("topic", "TopicList"),650 ("subtopic", "SubtopicList")):651 dv = DataValidation(type="list", formula1=name, allow_blank=True,652 showDropDown=False, showErrorMessage=False)653 dv.prompt = f"Pick a {col} (depends on the level above)"654 dv.promptTitle = col655 ws.add_data_validation(dv)656 cl = get_column_letter(col_index[col])657 dv.add(f"{cl}2:{cl}{rows + 1}")658 659 return counts660 661 662def build_workbook(options, out_path, rows=1000, taxonomy=None, subjects=None,663 sample_rows=None):664 """Write the template with header columns + data-validation dropdowns.665 666 Flat-list option values live on a hidden '_lists' sheet (one column each) and667 the validations reference those ranges — this avoids Excel's 255-char inline668 limit and survives commas inside option text.669 670 When taxonomy data is available, Subject/Chapter/Topic/Subtopic become671 dependent (cascading) dropdowns instead (see write_cascade).672 """673 if subjects is None:674 subjects = SUBJECT_LIST675 if taxonomy is None:676 taxonomy = load_taxonomy(subjects=subjects)677 cascade_active = bool(taxonomy)678 679 wb = Workbook()680 ws = wb.active681 ws.title = "Tagging"682 ws.append(COLUMN_ORDER)683 684 for row in (sample_rows or []):685 ws.append([row.get(col, "") for col in COLUMN_ORDER])686 687 lists = wb.create_sheet("_lists")688 lists.sheet_state = "hidden"689 690 col_index = {name: i + 1 for i, name in enumerate(COLUMN_ORDER)}691 692 # Subject is handled by the cascade when taxonomy is present; don't also give693 # it a flat scraped-list dropdown (that would be a duplicate validation).694 cascade_cols = {"subject", "chapter", "topic", "subtopic"} if cascade_active else set()695 696 lcol = 0697 for field in SCRAPE_FIELDS:698 col = field["col"]699 if col in cascade_cols:700 continue701 values = options.get(col) or field.get("default", [])702 # Skip when there's nothing to offer, or the list is too big for a picker.703 if not values or len(values) > MAX_DROPDOWN_OPTIONS:704 continue705 lcol += 1706 lcol_letter = get_column_letter(lcol)707 lists.cell(row=1, column=lcol, value=col) # header for clarity708 for r, val in enumerate(values, start=2):709 lists.cell(row=r, column=lcol, value=val)710 ref = f"_lists!${lcol_letter}$2:${lcol_letter}${len(values) + 1}"711 712 dv = DataValidation(type="list", formula1=ref, allow_blank=True,713 showDropDown=False)714 dv.error = f"Pick a value from the {col} list."715 dv.errorTitle = "Invalid value"716 dv.prompt = f"Choose a {col}"717 dv.promptTitle = col718 ws.add_data_validation(dv)719 720 col_letter = get_column_letter(col_index[col])721 dv.add(f"{col_letter}2:{col_letter}{rows + 1}")722 723 if cascade_active:724 write_cascade(wb, ws, col_index, taxonomy, subjects, rows)725 726 # widen the header columns a touch for readability727 for name, idx in col_index.items():728 ws.column_dimensions[get_column_letter(idx)].width = max(12, min(len(name) + 4, 26))729 730 wb.save(out_path)731 732 733# ── CLI ───────────────────────────────────────────────────────────────────────734 735def main():736 ap = argparse.ArgumentParser(description="Generate the CMS tagging Excel template "737 "with live-scraped dropdowns + data validation.")738 ap.add_argument("--state", help="Playwright storage_state JSON (default: newest saved session)")739 ap.add_argument("--qid", help="Existing question id to open via Edit Details (most reliable)")740 ap.add_argument("--out", default=os.path.join(DATA_DIR, "tagging_template.xlsx"),741 help="Output .xlsx path (default: tagging_template.xlsx)")742 ap.add_argument("--no-scrape", action="store_true",743 help="Skip CMS; build the template from built-in default lists")744 ap.add_argument("--from-lookups", action="store_true",745 help="Fill Other-Details dropdowns from the QB /lookups API "746 "(no browser) instead of scraping the form with Playwright")747 ap.add_argument("--headed", dest="headless", action="store_false",748 help="Show the browser window (default)")749 ap.add_argument("--headless", dest="headless", action="store_true",750 help="Run the browser headless")751 ap.add_argument("--toc", default=TAXONOMY_CSV,752 help="Subject/Chapter/Topic/Sub-Topic CSV for the cascading dropdowns")753 ap.add_argument("--no-cascade", action="store_true",754 help="Skip the dependent Subject→Chapter→Topic→Subtopic dropdowns")755 ap.add_argument("--from-cms", action="store_true",756 help="Build the Subject→Chapter→Topic→Subtopic cascade LIVE from "757 "the CMS community API instead of the offline CSV/xlsx")758 ap.add_argument("--examples", action="store_true",759 help="Include illustrative example rows (for the sample workbook)")760 ap.set_defaults(headless=False)761 args = ap.parse_args()762 763 options = {}764 if args.no_scrape:765 print("Skipping scrape — using built-in default lists.")766 elif args.from_lookups:767 state_file = pick_state_file(args.state)768 if not state_file:769 sys.exit("--from-lookups needs a saved CMS session (none found). "770 "Connect on /tagger first, or pass --state.")771 print(f"Fetching Other-Details options from /lookups using: {state_file}")772 try:773 options = lookups_options(state_file)774 for col, vals in options.items():775 print(f" lookups {col:<14} {len(vals)} option(s)")776 except Exception as e:777 print(f"/lookups fetch failed ({e}). Falling back to built-in default lists.")778 else:779 state_file = pick_state_file(args.state)780 if not state_file:781 print("No saved CMS session found — falling back to built-in default lists.\n"782 " (Set up the CMS login first, or pass --state, to scrape live options.)")783 else:784 print(f"Using CMS session: {state_file}")785 try:786 options = scrape_options(state_file, args.qid, args.headless)787 except Exception as e:788 print(f"Scrape failed ({e}). Falling back to built-in default lists.")789 790 # Report what each scrape-able column will use for its dropdown.791 print("\nColumn dropdown sources:")792 validated = []793 for f in SCRAPE_FIELDS:794 col = f["col"]795 if options.get(col):796 print(f" {col:<14} {len(options[col])} scraped option(s)")797 validated.append(col)798 elif f.get("default"):799 print(f" {col:<14} default list ({len(f['default'])})")800 validated.append(col)801 else:802 print(f" {col:<14} no options found — left as free text")803 804 if args.no_cascade:805 taxonomy = {}806 elif args.from_cms:807 print("\nBuilding Subject -> Chapter -> Topic -> Subtopic cascade LIVE from CMS "808 "(community API)...")809 try:810 taxonomy = cms_taxonomy()811 except Exception as e:812 print(f"CMS taxonomy build failed ({e}). Falling back to offline CSV/xlsx.")813 taxonomy = load_taxonomy(args.toc)814 else:815 taxonomy = load_taxonomy(args.toc)816 if not args.no_cascade:817 if taxonomy:818 print("\nCascading taxonomy (Subject -> Chapter -> Topic -> Subtopic):")819 for s in SUBJECT_LIST:820 t = taxonomy.get(s)821 if t:822 nt = sum(len(v) for v in t.values())823 nst = sum(len(st) for v in t.values() for st in v.values())824 print(f" {s:<12} {len(t)} chapters, {nt} topics, {nst} subtopics")825 else:826 print(f" {s:<12} (no offline data — cascade empty; scrape from CMS later)")827 else:828 print(f"\nNo taxonomy CSV at {args.toc} — Subject/Chapter/Topic/Subtopic "829 "will be plain columns.")830 831 # Pass taxonomy through so build_workbook doesn't re-read the CSV.832 build_workbook(options, args.out, taxonomy=(taxonomy or None),833 sample_rows=(EXAMPLE_ROWS if args.examples else None))834 cascade_on = bool(taxonomy)835 free_cols = [f["col"] for f in FIELDS if f["mode"] == "free"836 and not (cascade_on and f["col"] in ("chapter", "topic", "subtopic"))]837 print(f"\nTemplate written: {args.out}")838 print("Columns:", ", ".join(COLUMN_ORDER))839 if cascade_on:840 print("Cascading dropdowns: subject -> chapter -> topic -> subtopic")841 validated = [c for c in validated if c != "subject"]842 print("Flat dropdowns:", ", ".join(validated))843 print("Free text:", ", ".join(free_cols))844 845 846if __name__ == "__main__":847 main()848 