Changeset ad88282 in flexograder


Ignore:
Timestamp:
10/17/25 15:17:02 (3 months ago)
Author:
Enrico Schwass <ennoausberlin@…>
Branches:
master
Children:
24fc2dd
Parents:
33c7134
Message:

put Exam under FlexoEntity

Files:
3 added
7 edited

Legend:

Unmodified
Added
Removed
  • builder/catalog_manager.py

    r33c7134 rad88282  
     1from pathlib import Path
     2import json
    13from typing import Dict, Optional
     4
    25from .question_catalog import QuestionCatalog
     6from .question_factory import question_factory
     7from .id_factory import EntityType
     8from .flexo_entity import FlexoEntity
    39
    410
    511class 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)
    717        self._catalogs: Dict[str, QuestionCatalog] = {}
    818        self._active_id: Optional[str] = None
    919
    10     # --- Catalog management ---
    11     def add_catalog(self, catalog: QuestionCatalog):
    12         cid = catalog.meta["catalog_id"]
    13         self._catalogs[cid] = catalog
    14         if self._active_id is None:
    15             self._active_id = cid  # first loaded catalog becomes active
     20    # ───────────────────────────────────────────────────────────────
     21    # Accessors
     22    # ───────────────────────────────────────────────────────────────
     23    def list_catalogs(self):
     24        """Return sorted list of known catalog IDs."""
     25        return sorted(self._catalogs.keys())
    1626
    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
    2032
    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."""
    2286        if catalog_id in self._catalogs:
    2387            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}")
    2494            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
    2698
    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.")
    29107
    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  
    11import json
     2from dataclasses import dataclass, field
     3from datetime import datetime
     4from typing import Optional, List, Dict, Any
     5from pathlib import Path
    26import zipfile
    3 from datetime import datetime
    4 from pathlib import Path
    5 from typing import Optional, List, Dict, Any
    6 
     7
     8from .id_factory import EntityType, EntityState
     9from .flexo_entity import FlexoEntity
    710from .questions import Question
    8 
    911from .question_factory import question_factory
    1012
    1113
    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
    2515class 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)
    3224
    3325    def to_dict(self):
    3426        return {
    35             "title": self._title,
     27            "title": self.title,
    3628            "questions": [q.to_dict() for q in self.questions],
    3729        }
    3830
    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)
    5033
    5134
    5235# --- Exam Container ---
    53 class Exam:
     36@dataclass
     37class 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
    5491
    5592    @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):
    58116        with open(filename, "r", encoding="utf-8") as f:
    59117            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
    103128
    104129    @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]
    147133
    148134    # --- Core API ---
     
    162148        page.add_question(question)
    163149
    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 
    179150    @property
    180151    def is_tainted(self) -> bool:
    181152        """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)
    188154
    189155    def shuffle_options(self):
  • builder/question_catalog.py

    r33c7134 rad88282  
    11import re
    22from datetime import datetime
    3 from .questions import QuestionStatus, Question
     3from dataclasses import dataclass, field
     4from typing import List, Optional
     5from .questions import Question
     6from .flexo_entity import FlexoEntity
     7from .id_factory import EntityType, EntityState
    48
    59
    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
     11class 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)
    2016
    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
    4922
    5023    @property
     
    5326
    5427    @property
    55     def drafts(self):
    56         return [q for q in self.questions if q.status == QuestionStatus.DRAFT]
     28    def draft_questions(self):
     29        return [q for q in self.questions if q.state == EntityState.DRAFT]
    5730
    5831    @property
    59     def obsolete(self):
    60         return [q for q in self.questions if q.status == QuestionStatus.OBSOLETE]
     32    def obsolete_questions(self):
     33        return [q for q in self.questions if q.state == EntityState.OBSOLETE]
    6134
    6235    @property
    63     def has_unapproved(self) -> bool:
    64         return any(q.status != 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)
    6538
    6639    def not_approved(self) -> list[Question]:
    67         return [q for q in self.questions.values() if q.status != QuestionStatus.APPROVED]
     40        return [q for q in self.questions.values() if q.state != EntityState.APPROVED]
    6841
    6942    @property
    70     def status_text(self):
    71         if self.empty:
     43    def status_text(self) -> str:
     44        if not self.questions:
    7245            return "empty"
    73         if self.obsolete:
     46        if any(q.state == EntityState.OBSOLETE for q in self.questions):
    7447            return "obsolete"
    75         if self.drafts:
     48        if any(q.state == EntityState.DRAFT for q in self.questions):
    7649            return "draft"
    77         if not self.has_unapproved:
     50        if all(q.state == EntityState.APPROVED for q in self.questions):
    7851            return "approved"
    79         return "unknown"
     52        return "mixed"
    8053
    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)
    8260
    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
    9064
    9165    @property
    9266    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})
    9568        return self._domains
    9669
    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()
    10076
    10177    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         """
    10778        q = self.find(qid)
    10879        if not q:
    10980            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()
    11885        return True
    11986
    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)
    12289
    123     def filter(self, *, domain=None, topic=None, qtype=None):
     90    def filter(self, *, domain=None, topic=None, qtype=None) -> List[Question]:
    12491        qs = self.questions
    12592        if domain:
    12693            qs = [q for q in qs if q.domain == domain]
    12794        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]
    12996        if qtype:
    130             qs = [q for q in qs if q.type == qtype]
     97            qs = [q for q in qs if q.qtype == qtype]
    13198        return qs
    13299
    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}"
    137112
    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
    143124
    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"),
    174132        )
     133        obj.questions = [question_factory(qd) for qd in data.get("questions", [])]
     134        return obj
  • builder/question_factory.py

    r33c7134 rad88282  
    7070
    7171def question_factory(q: dict):
    72     qtype = q["type"]
     72    qtype = q["qtype"]
    7373    try:
    7474        return getattr(QuestionTypes, qtype)(q)
  • builder/questions.py

    r33c7134 rad88282  
    6262        if not isinstance(other, Question):
    6363            return False
    64         return self.id == other.id and self.type == other.type
     64        return self.id == other.id and self.qtype == other.qtype
    6565
    6666    def __hash__(self):
    67         return hash((self.id, self.type))
     67        return hash((self.id, self.qtype))
    6868
    6969    @property
     
    9292
    9393    def to_dict(self):
    94         base = super().to_dict()
     94        base = super().to_dict()  # from FlexoEntity
    9595        base.update({
    96             "domain": self.domain,
    9796            "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,
    10199            "media": [m.to_dict() for m in self.media if not isinstance(m, NullMediaItem)],
    102             "status": self.status
     100            "state": self.state.name,  # explicit readable form
    103101        })
     102        return base
    104103
    105104    def shuffle_answers(self):
     
    322321            validation = each_field.get("validation", {})
    323322
    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"']
    325324            if validation.get("required"):
    326325                attr_parts.append("required")
  • examples/demo/example_exam.json

    r33c7134 rad88282  
    1717                {
    1818                    "id": "IDENT_001",
    19                     "type": "candidate_id",
     19                    "qtype": "candidate_id",
    2020                    "domain": "SYS_IDENT",
    2121                    "topic": "identification",
     
    5858                {
    5959                    "id": "INFO_801",
    60                     "type": "instruction",
     60                    "qtype": "instruction",
    6161                    "domain": "SYS_INFO",
    6262                    "topic": "certification",
     
    7171                {
    7272                    "id": "SIGNALS_802",
    73                     "type": "radio",
     73                    "qtype": "radio",
    7474                    "domain": "SIGNALS_SIGNALS",
    7575                    "topic": "forms",
     
    8484                {
    8585                    "id": "SIGNALS_803",
    86                     "type": "checkbox",
     86                    "qtype": "checkbox",
    8787                    "domain": "SIGNALS_SIGNALS",
    8888                    "topic": "forms",
     
    102102                {
    103103                    "id": "MILITARY_804",
    104                     "type": "radio",
     104                    "qtype": "radio",
    105105                    "domain": "MILITARY_SYMBOLS",
    106106                    "topic": "radar",
     
    119119                {
    120120                    "id": "SIGNALS_805",
    121                     "type": "radio",
     121                    "qtype": "radio",
    122122                    "domain": "SIGNALS_SIGNALS",
    123123                    "topic": "sound",
     
    131131                {
    132132                    "id": "SIGNALS_806",
    133                     "type": "radio",
     133                    "qtype": "radio",
    134134                    "domain": "SIGNALS_SIGNALS",
    135135                    "topic": "visual",
  • tests/test_load_exam.py

    r33c7134 rad88282  
    1818
    1919    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"
    2224
    2325    def test_page_count(self, exam):
Note: See TracChangeset for help on using the changeset viewer.