Changeset ad88282 in flexograder
- Timestamp:
- 10/17/25 15:17:02 (3 months ago)
- Branches:
- master
- Children:
- 24fc2dd
- Parents:
- 33c7134
- Files:
-
- 3 added
- 7 edited
-
builder/catalog_manager.py (modified) (1 diff)
-
builder/exam.py (modified) (2 diffs)
-
builder/question_catalog.py (modified) (2 diffs)
-
builder/question_factory.py (modified) (1 diff)
-
builder/questions.py (modified) (3 diffs)
-
examples/demo/example_exam.json (modified) (7 diffs)
-
tests/test_catalog_manager.py (added)
-
tests/test_exam.py (added)
-
tests/test_load_exam.py (modified) (1 diff)
-
tests/test_question_catalog.py (added)
Legend:
- Unmodified
- Added
- Removed
-
builder/catalog_manager.py
r33c7134 rad88282 1 from pathlib import Path 2 import json 1 3 from typing import Dict, Optional 4 2 5 from .question_catalog import QuestionCatalog 6 from .question_factory import question_factory 7 from .id_factory import EntityType 8 from .flexo_entity import FlexoEntity 3 9 4 10 5 11 class CatalogManager: 6 def __init__(self): 12 """Manages loading, saving, and tracking of QuestionCatalogs.""" 13 14 def __init__(self, base_path: str = "catalogs"): 15 self.base_path = Path(base_path) 16 self.base_path.mkdir(parents=True, exist_ok=True) 7 17 self._catalogs: Dict[str, QuestionCatalog] = {} 8 18 self._active_id: Optional[str] = None 9 19 10 # --- Catalog management ---11 def add_catalog(self, catalog: QuestionCatalog):12 cid = catalog.meta["catalog_id"]13 self._catalogs[cid] = catalog14 if self._active_id is None:15 self._active_id = cid # first loaded catalog becomes active20 # ─────────────────────────────────────────────────────────────── 21 # Accessors 22 # ─────────────────────────────────────────────────────────────── 23 def list_catalogs(self): 24 """Return sorted list of known catalog IDs.""" 25 return sorted(self._catalogs.keys()) 16 26 17 @property 18 def catalogs(self): 19 return self._catalogs 27 def get_active(self) -> Optional[QuestionCatalog]: 28 """Return the currently active catalog, if any.""" 29 if self._active_id: 30 return self._catalogs.get(self._active_id) 31 return None 20 32 21 def remove_catalog(self, catalog_id: str): 33 def get_active_title(self) -> str: 34 if self._active_id and self._active_id in self._catalogs: 35 return self._catalogs[self._active_id].title 36 return "" 37 38 # ─────────────────────────────────────────────────────────────── 39 # Load / Save 40 # ─────────────────────────────────────────────────────────────── 41 def load(self, path: str) -> QuestionCatalog: 42 """Load catalog from JSON file into memory.""" 43 with open(path, "r", encoding="utf-8") as f: 44 data = json.load(f) 45 46 catalog = QuestionCatalog.from_dict(data, question_factory) 47 if isinstance(catalog, QuestionCatalog): 48 # Verify signature/fingerprint 49 if not FlexoEntity.verify_integrity(catalog): 50 print(f"[WARN] Integrity mismatch in {path}") 51 self._catalogs[str(catalog.flexo_id)] = catalog 52 self._active_id = str(catalog.flexo_id) 53 return catalog 54 55 def save(self, catalog: Optional[QuestionCatalog] = None): 56 """Save catalog to JSON file.""" 57 cat = catalog or self.get_active() 58 if not cat: 59 raise RuntimeError("No active catalog to save") 60 61 filename = f"{cat.title.replace(' ', '_')}_{cat.version:03d}.json" 62 path = self.base_path / filename 63 with open(path, "w", encoding="utf-8") as f: 64 json.dump(cat.to_dict(), f, indent=2, ensure_ascii=False) 65 print(f"[INFO] Saved catalog {cat.title} → {path}") 66 return path 67 68 # ─────────────────────────────────────────────────────────────── 69 # CRUD operations 70 # ─────────────────────────────────────────────────────────────── 71 def create(self, domain: str, title: str, author: str = "unknown") -> QuestionCatalog: 72 """Create and register a new empty catalog.""" 73 catalog = QuestionCatalog( 74 domain=domain, 75 etype=EntityType.CATALOG, 76 text_seed=title, 77 title=title, 78 author=author, 79 ) 80 self._catalogs[str(catalog.flexo_id)] = catalog 81 self._active_id = str(catalog.flexo_id) 82 return catalog 83 84 def delete(self, catalog_id: str) -> bool: 85 """Delete a catalog from memory and disk.""" 22 86 if catalog_id in self._catalogs: 23 87 del self._catalogs[catalog_id] 88 # Delete files matching pattern 89 for file in self.base_path.glob(f"*{catalog_id}*.json"): 90 try: 91 file.unlink() 92 except Exception as e: 93 print(f"[WARN] Could not delete {file}: {e}") 24 94 if self._active_id == catalog_id: 25 self._active_id = next(iter(self._catalogs), None) 95 self._active_id = None 96 return True 97 return False 26 98 27 def get_catalog(self, catalog_id: str) -> Optional[QuestionCatalog]: 28 return self._catalogs.get(catalog_id) 99 # ─────────────────────────────────────────────────────────────── 100 # Utilities 101 # ─────────────────────────────────────────────────────────────── 102 def mark_all_obsolete(self): 103 """Mark all catalogs as obsolete (e.g. for archival runs).""" 104 for cat in self._catalogs.values(): 105 cat.obsolete() 106 print("[INFO] All catalogs marked obsolete.") 29 107 30 def list_catalogs(self) -> list[str]: 31 return list(self._catalogs.keys()) 32 33 # --- Active catalog handling --- 34 def set_active(self, catalog_id: str): 35 if catalog_id not in self._catalogs: 36 raise ValueError(f"No such catalog: {catalog_id}") 37 self._active_id = catalog_id 38 39 def get_active(self) -> Optional[QuestionCatalog]: 40 if self._active_id is not None: 41 return self._catalogs[self._active_id] 42 return None 43 44 def get_active_title(self): 45 if self.get_active() is None: 46 return "No catalog selected" 47 return self.get_active().title 48 49 def get_active_id(self) -> Optional[str]: 50 return self._active_id 108 def approve_active(self): 109 """Approve the currently active catalog (bump version).""" 110 cat = self.get_active() 111 if cat: 112 cat.approve() 113 print(f"[INFO] Catalog {cat.title} approved → {cat.flexo_id}") -
builder/exam.py
r33c7134 rad88282 1 1 import json 2 from dataclasses import dataclass, field 3 from datetime import datetime 4 from typing import Optional, List, Dict, Any 5 from pathlib import Path 2 6 import zipfile 3 from datetime import datetime 4 from pathlib import Path 5 from typing import Optional, List, Dict, Any 6 7 8 from .id_factory import EntityType, EntityState 9 from .flexo_entity import FlexoEntity 7 10 from .questions import Question 8 9 11 from .question_factory import question_factory 10 12 11 13 12 class QuestionRef: 13 def __init__(self, catalog_id, question_id): 14 self.catalog_id = catalog_id 15 self.id = question_id 16 17 def to_dict(self): 18 return {"catalog_id": self.catalog_id, "question_id": self.id} 19 20 @staticmethod 21 def from_dict(data): 22 return QuestionRef(data["catalog_id"], data["question_id"]) 23 24 14 @dataclass 25 15 class ExamPage: 26 def __init__(self, title, questions: List[Question]): 27 self._title = title 28 self.questions = questions 29 30 def add_question(self, catalog_id, question_id): 31 self.questions.append(QuestionRef(catalog_id, question_id)) 16 title: str 17 questions: List[Question] = field(default_factory=list) 18 19 @classmethod 20 def from_dict(cls, data: dict): 21 """Rebuild a page with fully embedded Question objects.""" 22 questions = [question_factory(q) for q in data.get("questions", [])] 23 return cls(title=data.get("title", ""), questions=questions) 32 24 33 25 def to_dict(self): 34 26 return { 35 "title": self. _title,27 "title": self.title, 36 28 "questions": [q.to_dict() for q in self.questions], 37 29 } 38 30 39 @staticmethod 40 def from_dict(data): 41 qs = [QuestionRef.from_dict(q) for q in data.get("questions", [])] 42 return ExamPage(title=data.get("title", ""), questions=qs) 43 44 def to_list(self): 45 return [q.to_dict() for q in self.questions] 46 47 @property 48 def title(self): 49 return self._title 31 def add_question(self, question: Question): 32 self.questions.append(question) 50 33 51 34 52 35 # --- Exam Container --- 53 class Exam: 36 @dataclass 37 class Exam(FlexoEntity): 38 title: str = "" 39 duration: str = "" 40 allowed_aids: str = "" 41 headline: str = "" 42 intro_note: str = "" 43 submit_note: str = "" 44 pages: List[ExamPage] = field(default_factory=list) 45 meta_extra: Optional[Dict[str, Any]] = field(default_factory=dict) 46 author: str = "unknown" 47 48 def __post_init__(self): 49 self.etype = EntityType.EXAM 50 # use combined exam info as seed 51 if not self.text_seed: 52 self.text_seed = self.title or "Untitled Exam" 53 super().__post_init__() 54 55 # ------------------------------------------------------------------------- 56 # Content-dependent fingerprint update 57 # ------------------------------------------------------------------------- 58 def update_text_seed(self): 59 """Combine title + question text for fingerprint tracking.""" 60 parts = [self.title] 61 for page in self.pages: 62 for q in getattr(page, "questions", []): 63 if hasattr(q, "text_seed"): 64 parts.append(q.text_seed) 65 elif hasattr(q, "text"): 66 parts.append(q.text) 67 self.text_seed = " ".join(parts).strip() 68 self._update_fingerprint() 69 70 # ------------------------------------------------------------------------- 71 # Serialization 72 # ------------------------------------------------------------------------- 73 74 def to_dict(self): 75 base = super().to_dict() 76 base.update({ 77 "meta": { 78 "exam_id": str(self.flexo_id), 79 "title": self.title, 80 "duration": self.duration, 81 "allowed_aids": self.allowed_aids, 82 "headline": self.headline, 83 "intro_note": self.intro_note, 84 "submit_note": self.submit_note, 85 "author": self.author, 86 **self.meta_extra, 87 }, 88 "pages": [p.to_dict() for p in self.pages], 89 }) 90 return base 54 91 55 92 @classmethod 56 def from_json_file(cls, filename: str) -> "Exam": 57 93 def from_dict(cls, data): 94 meta = data.get("meta", {}) 95 pages = [ExamPage.from_dict(p) for p in data.get("pages", [])] 96 return cls( 97 domain=meta.get("domain", "AF"), 98 etype=EntityType.EXAM, 99 text_seed=meta.get("title", ""), 100 title=meta.get("title", ""), 101 duration=meta.get("duration", ""), 102 allowed_aids=meta.get("allowed_aids", ""), 103 headline=meta.get("headline", ""), 104 intro_note=meta.get("intro_note", ""), 105 submit_note=meta.get("submit_note", ""), 106 pages=pages, 107 meta_extra={k: v for k, v in meta.items() if k not in { 108 "title", "duration", "allowed_aids", "headline", 109 "intro_note", "submit_note", "author" 110 }}, 111 author=meta.get("author", "unknown"), 112 ) 113 114 @classmethod 115 def from_json_file(cls, filename: str): 58 116 with open(filename, "r", encoding="utf-8") as f: 59 117 data = json.load(f) 60 61 pages = [ExamPage(page["title"], [question_factory(q) for q in page["questions"]]) for page in data["pages"]] 62 63 meta = data["meta"] 64 base_keys = ["title", "duration", "headline", "allowed_aids", "intro_note", "submit_note"] 65 extra_keys = {k: v for k, v in meta.items() if k not in base_keys} 66 67 return cls( 68 title=meta["title"], 69 duration=meta["duration"], 70 allowed_aids=meta["allowed_aids"], 71 headline=meta["headline"], 72 intro_note=meta["intro_note"], 73 submit_note=meta["submit_note"], 74 pages=pages, 75 meta_extra=extra_keys 76 ) 77 78 @staticmethod 79 def from_dict(data): 80 meta = data.get("meta", {}) 81 exam = Exam( 82 meta.get("exam_id", "unknown"), 83 meta.get("title", "Untitled"), 84 meta.get("author", "unknown"), 85 pages=[ExamPage.from_dict(p) for p in data.get("pages", [])], 86 ) 87 return exam 88 89 def __init__(self, title: str, duration: str, allowed_aids: str, headline: str, 90 intro_note: str, submit_note: str, pages: List[ExamPage], 91 meta_extra: Optional[Dict[str, Any]] = None): 92 self._meta = { 93 "title": title, 94 "duration": duration, 95 "allowed_aids": allowed_aids, 96 "headline": headline, 97 "intro_note": intro_note, 98 "submit_note": submit_note, 99 } 100 if meta_extra: 101 self._meta.update(meta_extra) 102 self._pages = pages 118 return cls.from_dict(data) 119 120 def to_json(self, indent: int = 2) -> str: 121 """Return a JSON representation including all FlexO metadata.""" 122 return json.dumps(self.to_dict(), ensure_ascii=False, indent=indent) 123 124 def to_json_file(self, path: str | Path) -> Path: 125 path = Path(path) 126 path.write_text(self.to_json(), encoding="utf-8") 127 return path 103 128 104 129 @property 105 def duration(self): 106 return self._meta["duration"] 107 108 @property 109 def allowed_aids(self): 110 return self._meta["allowed_aids"] 111 112 @property 113 def headline(self): 114 return self._meta["headline"] 115 116 @property 117 def intro_note(self): 118 return self._meta["intro_note"] 119 120 @property 121 def pages(self): 122 return self._pages 123 124 @property 125 def meta(self): 126 return self._meta 127 128 @property 129 def id(self): 130 return self._meta["exam_id"] 131 132 @property 133 def submit_note(self): 134 return self._meta["submit_note"] 135 136 @property 137 def title(self): 138 return self._meta["title"] 139 140 @property 141 def entries(self): 142 """Flattened list of (catalog_id, question_id) pairs from all pages.""" 143 pairs = [] 144 for page in self.pages: 145 pairs.extend([(q.catalog_id, q.id) for q in page.questions]) 146 return pairs 130 def questions(self): 131 """Flat list of all embedded Question objects across all pages.""" 132 return [q for page in self.pages for q in page.questions] 147 133 148 134 # --- Core API --- … … 162 148 page.add_question(question) 163 149 164 # --- Serialization ---165 def to_dict(self):166 return {167 "meta": {168 "exam_id": self.exam_id,169 "title": self.title,170 "author": self.author,171 "created_on": self.created_on,172 },173 "pages": [p.to_dict() for p in self.pages],174 }175 176 def to_json(self):177 return json.dumps(self.to_dict(), ensure_ascii=False, indent=2)178 179 150 @property 180 151 def is_tainted(self) -> bool: 181 152 """True if any question is not approved.""" 182 return any(q.status != QuestionStatus.APPROVED for q in self.questions) 183 184 @property 185 def questions(self): 186 """Return a flat list of all Question objects across all pages.""" 187 return [q for page in self.pages for q in page.questions] 153 return any(q.status != EntityState.APPROVED for q in self.questions) 188 154 189 155 def shuffle_options(self): -
builder/question_catalog.py
r33c7134 rad88282 1 1 import re 2 2 from datetime import datetime 3 from .questions import QuestionStatus, Question 3 from dataclasses import dataclass, field 4 from typing import List, Optional 5 from .questions import Question 6 from .flexo_entity import FlexoEntity 7 from .id_factory import EntityType, EntityState 4 8 5 9 6 class QuestionCatalog: 7 def __init__(self, catalog_id, title, author="unknown", version=None, questions=None): 8 self.meta = { 9 "catalog_id": catalog_id, 10 "title": title, 11 "created_on": datetime.utcnow().isoformat() + "Z", 12 "author": author, 13 "version": version or self.generate_version(), 14 } 15 if questions: 16 self._questions = set(questions) 17 else: 18 self._questions = set() 19 self._domains = set() 10 @dataclass 11 class QuestionCatalog(FlexoEntity): 12 title: str = "" 13 author: str = "unknown" 14 questions: List[Question] = field(default_factory=list) 15 _domains: set[str] = field(default_factory=set, init=False) 20 16 21 def generate_version(self): 22 """Generate a version string like 2025.10.14-1, incrementing if others exist.""" 23 today = datetime.utcnow().strftime("%Y.%m.%d") 24 existing = getattr(self, "_version_registry", {}) 25 count = existing.get(today, 0) + 1 26 existing[today] = count 27 self._version_registry = existing 28 return f"{today}-{count}" 29 30 @property 31 def version(self): 32 return self.meta.get("version", self.generate_version()) 33 34 @property 35 def title(self): 36 return self.meta["title"] 37 38 @property 39 def catalog_id(self): 40 return self.meta["catalog_id"] 41 42 @catalog_id.setter 43 def catalog_id(self, new_id): 44 self.meta["catalog_id"] = new_id 45 46 @property 47 def questions(self): 48 return sorted(self._questions, key=lambda question: question.id) 17 def __post_init__(self): 18 self.etype = EntityType.CATALOG 19 super().__post_init__() 20 if not self.title: 21 self.title = self.text_seed 49 22 50 23 @property … … 53 26 54 27 @property 55 def draft s(self):56 return [q for q in self.questions if q.stat us == QuestionStatus.DRAFT]28 def draft_questions(self): 29 return [q for q in self.questions if q.state == EntityState.DRAFT] 57 30 58 31 @property 59 def obsolete (self):60 return [q for q in self.questions if q.stat us == QuestionStatus.OBSOLETE]32 def obsolete_questions(self): 33 return [q for q in self.questions if q.state == EntityState.OBSOLETE] 61 34 62 35 @property 63 def has_unapproved (self) -> bool:64 return any(q.stat us != QuestionStatus.APPROVED for q in self.questions)36 def has_unapproved_questions(self) -> bool: 37 return any(q.state != EntityState.APPROVED for q in self.questions) 65 38 66 39 def not_approved(self) -> list[Question]: 67 return [q for q in self.questions.values() if q.stat us != QuestionStatus.APPROVED]40 return [q for q in self.questions.values() if q.state != EntityState.APPROVED] 68 41 69 42 @property 70 def status_text(self) :71 if self.empty:43 def status_text(self) -> str: 44 if not self.questions: 72 45 return "empty" 73 if self.obsolete:46 if any(q.state == EntityState.OBSOLETE for q in self.questions): 74 47 return "obsolete" 75 if self.drafts:48 if any(q.state == EntityState.DRAFT for q in self.questions): 76 49 return "draft" 77 if not self.has_unapproved:50 if all(q.state == EntityState.APPROVED for q in self.questions): 78 51 return "approved" 79 return " unknown"52 return "mixed" 80 53 81 # FIXME: Check that the abbreviation not yet exist 54 # ───────────────────────────────────────────── 55 # Domain management 56 # ───────────────────────────────────────────── 57 def add_domain(self, a_domain: str): 58 if self.validate_domain(a_domain) and a_domain not in self._domains: 59 self._domains.add(a_domain) 82 60 83 def add_domain(self, a_domain): 84 if self.validate_domain(a_domain) and a_domain not in self._domains: 85 self._domains.update([a_domain]) 86 87 def validate_domain(self, a_domain): 88 pattern = r"(?P<abbrev>[A-Z]+)_(?P<fulltext>[A-Za-z0-9]+)" 89 return re.fullmatch(pattern, a_domain) 61 @staticmethod 62 def validate_domain(a_domain: str) -> bool: 63 return re.fullmatch(r"(?P<abbrev>[A-Z]+)_(?P<fulltext>[A-Za-z0-9]+)", a_domain) is not None 90 64 91 65 @property 92 66 def domains(self): 93 domains_inside_questions = set([each.domain for each in self.questions]) 94 self._domains.update(domains_inside_questions) 67 self._domains.update({q.domain for q in self.questions}) 95 68 return self._domains 96 69 97 # --- Core operations --- 98 def add_questions(self, some_questions): 99 self._questions.update(some_questions) 70 # ───────────────────────────────────────────── 71 # Core operations 72 # ───────────────────────────────────────────── 73 def add_questions(self, some_questions: List[Question]): 74 self.questions.extend(q for q in some_questions if q not in self.questions) 75 self.updated_at = datetime.utcnow() 100 76 101 77 def remove(self, qid: str) -> bool: 102 """103 Remove a question by ID, but only if it is in DRAFT state.104 Returns True if removed, False if not found.105 Raises ValueError if the question exists but is not deletable.106 """107 78 q = self.find(qid) 108 79 if not q: 109 80 return False 110 111 if q.status is not QuestionStatus.DRAFT: 112 raise ValueError( 113 f"Cannot delete question {qid}: " 114 f"status is {q.status.name}, only DRAFT questions may be removed." 115 ) 116 117 self._questions.remove(q) 81 if q.state is not EntityState.DRAFT: 82 raise ValueError(f"Cannot delete {qid}: only DRAFT questions may be removed.") 83 self.questions.remove(q) 84 self.updated_at = datetime.utcnow() 118 85 return True 119 86 120 def find(self, qid ):121 return next((q for q in self.questions if q.id== qid), None)87 def find(self, qid: str) -> Optional[Question]: 88 return next((q for q in self.questions if getattr(q, "id", None) == qid), None) 122 89 123 def filter(self, *, domain=None, topic=None, qtype=None) :90 def filter(self, *, domain=None, topic=None, qtype=None) -> List[Question]: 124 91 qs = self.questions 125 92 if domain: 126 93 qs = [q for q in qs if q.domain == domain] 127 94 if topic: 128 qs = [q for q in qs if q.topic== topic]95 qs = [q for q in qs if getattr(q, "topic", None) == topic] 129 96 if qtype: 130 qs = [q for q in qs if q. type == qtype]97 qs = [q for q in qs if q.qtype == qtype] 131 98 return qs 132 99 133 def next_question_id(self, domain): 134 """ 135 Generate the next question ID for a given domain. 136 Uses only the domain abbreviation (e.g. 'ELEK' from 'ELEK_Elektrotechnik'). 100 # ───────────────────────────────────────────── 101 # ID helper 102 # ───────────────────────────────────────────── 103 def next_question_id(self, domain: str) -> str: 104 abbrev = domain.split("_", 1)[0].upper() if "_" in domain else domain.upper() 105 existing_numbers = [ 106 int(q.id.split("_")[-1]) 107 for q in self.questions 108 if q.id.startswith(f"{abbrev}_") and q.id.split("_")[-1].isdigit() 109 ] 110 next_num = max(existing_numbers, default=0) + 1 111 return f"{abbrev}_{next_num:03d}" 137 112 138 Example: 139 next_question_id("ELEK_Elektrotechnik") → "ELEK_001" 140 """ 141 # Extract the abbreviation before the first underscore 142 abbrev = domain.split("_", 1)[0].upper() if "_" in domain else domain.upper() 113 # ───────────────────────────────────────────── 114 # Serialization 115 # ───────────────────────────────────────────── 116 def to_dict(self): 117 d = super().to_dict() 118 d.update({ 119 "title": self.title, 120 "author": self.author, 121 "questions": [q.to_dict() for q in self.questions], 122 }) 123 return d 143 124 144 # Collect all existing numeric suffixes for this domain 145 existing_numbers = [] 146 for q in self._questions: 147 if q.id.startswith(f"{abbrev}_"): 148 parts = q.id.split("_") 149 if parts[-1].isdigit(): 150 existing_numbers.append(int(parts[-1])) 151 152 # Determine next number 153 next_num = max(existing_numbers, default=0) + 1 154 return f"{abbrev}_{next_num}" 155 156 # --- Serialization --- 157 def to_dict(self): 158 return { 159 "meta": self.meta, 160 "questions": [q.to_dict() for q in self.questions], 161 } 162 163 @staticmethod 164 def from_dict(data, question_factory): 165 """Rebuild catalog with help from a factory that creates proper Question objects.""" 166 meta = data["meta"] 167 questions = [question_factory(qdata) for qdata in data["questions"]] 168 return QuestionCatalog( 169 catalog_id=meta["catalog_id"], 170 title=meta["title"], 171 author=meta.get("author", "unknown"), 172 version=meta.get("version", "1.0"), 173 questions=questions, 125 @classmethod 126 def from_dict(cls, data: dict, question_factory) -> "QuestionCatalog": 127 obj = cls( 128 domain=data["domain"], 129 etype=EntityType.CATALOG, 130 text_seed=data.get("title", ""), 131 author=data.get("author", "unknown"), 174 132 ) 133 obj.questions = [question_factory(qd) for qd in data.get("questions", [])] 134 return obj -
builder/question_factory.py
r33c7134 rad88282 70 70 71 71 def question_factory(q: dict): 72 qtype = q[" type"]72 qtype = q["qtype"] 73 73 try: 74 74 return getattr(QuestionTypes, qtype)(q) -
builder/questions.py
r33c7134 rad88282 62 62 if not isinstance(other, Question): 63 63 return False 64 return self.id == other.id and self. type == other.type64 return self.id == other.id and self.qtype == other.qtype 65 65 66 66 def __hash__(self): 67 return hash((self.id, self. type))67 return hash((self.id, self.qtype)) 68 68 69 69 @property … … 92 92 93 93 def to_dict(self): 94 base = super().to_dict() 94 base = super().to_dict() # from FlexoEntity 95 95 base.update({ 96 "domain": self.domain,97 96 "topic": self.topic, 98 "id": self.id, 99 "type": self.type, 100 "text": self.text, 97 "qtype": self.qtype, # avoid name clash with built-in type 98 "text": self.text_seed, 101 99 "media": [m.to_dict() for m in self.media if not isinstance(m, NullMediaItem)], 102 "stat us": self.status100 "state": self.state.name, # explicit readable form 103 101 }) 102 return base 104 103 105 104 def shuffle_answers(self): … … 322 321 validation = each_field.get("validation", {}) 323 322 324 attr_parts = [f'name="{field_id}"', f'id="{field_id}"', ' type="text"']323 attr_parts = [f'name="{field_id}"', f'id="{field_id}"', 'qtype="text"'] 325 324 if validation.get("required"): 326 325 attr_parts.append("required") -
examples/demo/example_exam.json
r33c7134 rad88282 17 17 { 18 18 "id": "IDENT_001", 19 " type": "candidate_id",19 "qtype": "candidate_id", 20 20 "domain": "SYS_IDENT", 21 21 "topic": "identification", … … 58 58 { 59 59 "id": "INFO_801", 60 " type": "instruction",60 "qtype": "instruction", 61 61 "domain": "SYS_INFO", 62 62 "topic": "certification", … … 71 71 { 72 72 "id": "SIGNALS_802", 73 " type": "radio",73 "qtype": "radio", 74 74 "domain": "SIGNALS_SIGNALS", 75 75 "topic": "forms", … … 84 84 { 85 85 "id": "SIGNALS_803", 86 " type": "checkbox",86 "qtype": "checkbox", 87 87 "domain": "SIGNALS_SIGNALS", 88 88 "topic": "forms", … … 102 102 { 103 103 "id": "MILITARY_804", 104 " type": "radio",104 "qtype": "radio", 105 105 "domain": "MILITARY_SYMBOLS", 106 106 "topic": "radar", … … 119 119 { 120 120 "id": "SIGNALS_805", 121 " type": "radio",121 "qtype": "radio", 122 122 "domain": "SIGNALS_SIGNALS", 123 123 "topic": "sound", … … 131 131 { 132 132 "id": "SIGNALS_806", 133 " type": "radio",133 "qtype": "radio", 134 134 "domain": "SIGNALS_SIGNALS", 135 135 "topic": "visual", -
tests/test_load_exam.py
r33c7134 rad88282 18 18 19 19 def test_metadata_is_correct(self, exam): 20 assert exam.meta["title"] == "DEMO EXAM" 21 assert exam.id == "DM-2025-10-001" 20 assert exam.title == "DEMO EXAM" 21 assert exam.duration == "30 Minuten" 22 assert exam.allowed_aids == "keine" 23 assert exam.meta_extra["created_by"] == "OSF Schwass" 22 24 23 25 def test_page_count(self, exam):
Note:
See TracChangeset
for help on using the changeset viewer.
