SRE.common
1import hashlib 2import json 3import re 4import unicodedata 5from dataclasses import dataclass, asdict, field 6from typing import List 7 8import msgpack 9from enum import Enum 10 11from . import access, params 12from .instructor_text import strip_instructor, unwrap_instructor 13 14# graphviz node sizes (inches) of the icons of the schema (GUI and export PDF) 15SWITCH_ICON_SIZE = ("0.5", "0.5") 16NETWORK_ICON_SIZE = ("1.0", "0.6") # a network drawn as an icon of params.network_shape_icons (a cloud) 17 18 19def icon_node_attrs(image: str, color: str | None, width: str, height: str) -> dict: 20 """Graphviz attributes of a network drawn as an icon with its label below: a box filled with 21 its colour behind the image when it has one, nothing otherwise.""" 22 attrs = {"image": image, "labelloc": "b", "width": width, "height": height, 23 "fixedsize": "true", "imagescale": "true", "fontsize": "16"} 24 if color: 25 attrs.update(shape="box", style="filled", fillcolor=color) 26 else: 27 attrs["shape"] = "none" 28 return attrs 29 30 31class TranslatedText(dict): 32 """A dict subclass holding translations keyed by language code (e.g. 'en', 'fr'). 33 34 Use ``resolve(lang)`` to get the text for a specific language, falling back to the 35 first available translation. ``from_value`` ensures backward-compat with plain strings. 36 """ 37 38 def resolve(self, lang: str, fallback: str = '') -> str: 39 return self.get(lang) or next(iter(self.values()), fallback) 40 41 def resolve_priority(self, langs: list, fallback: str = '') -> str: 42 """Return the first translation found in the priority list, or first available.""" 43 for lang in langs: 44 if self.get(lang): 45 return self[lang] 46 return next(iter(self.values()), fallback) 47 48 def __add__(self, other) -> 'TranslatedText': 49 if isinstance(other, str): 50 return TranslatedText({lang: self[lang] + other for lang in self}) 51 if not isinstance(other, TranslatedText): 52 return NotImplemented 53 if not self: 54 return TranslatedText(other) 55 if not other: 56 return TranslatedText(self) 57 # Merge mismatched language sets gracefully (partial translations): take 58 # the union of languages, falling back to each side's default-language 59 # (first-key) value for a language that side lacks. 60 self_default = next(iter(self.values())) 61 other_default = next(iter(other.values())) 62 langs = list(dict.fromkeys((*self, *other))) 63 return TranslatedText({ 64 lang: self.get(lang, self_default) + other.get(lang, other_default) 65 for lang in langs 66 }) 67 68 def __radd__(self, other) -> 'TranslatedText': 69 if isinstance(other, str): 70 return TranslatedText({lang: other + self[lang] for lang in self}) 71 return NotImplemented 72 73 def format(self, **kwargs) -> 'TranslatedText': 74 """Apply str.format(**kwargs) to every language value.""" 75 return TranslatedText({lang: text.format(**kwargs) for lang, text in self.items()}) 76 77 @classmethod 78 def from_value(cls, v, default_lang: str = 'en') -> 'TranslatedText': 79 if isinstance(v, cls): 80 return v 81 if isinstance(v, dict): 82 return cls(v) 83 return cls({default_lang: str(v)}) if v else cls() 84 85 86def _tt_hash_str(v) -> str: 87 """Stable string representation of a TranslatedText (or plain str) for hashing. The 88 instructor fragments are left out: a hash is the same in and out of the instructor mode.""" 89 return json.dumps(dict(sorted(strip_instructor(TranslatedText.from_value(v)).items())), ensure_ascii=False) 90 91 92class QuestionType(Enum): 93 DUMMY = 0 94 TEXT = 1 95 FORM = 2 96 97 98class Question: 99 """Abstract base for all question types (TEXT, FORM, DUMMY).""" 100 101 def __init__(self, title, description, question_hash, order, question_type): 102 self.title = title 103 self.description = description 104 self.question_hash = question_hash 105 self.order = order 106 self.question_type = question_type 107 108 109 110@dataclass 111class QuestionText(Question): 112 """A free-text answer question. Hash is derived from *title* if not provided.""" 113 114 title: TranslatedText | str 115 description: TranslatedText | str 116 question_hash: str 117 order: int 118 question_type: int = QuestionType.TEXT.value 119 120 def __init__(self, title, description='', question_hash=None, order=None, **kwargs): 121 super().__init__(title, description, question_hash, order, question_type=QuestionType.TEXT) 122 if question_hash is None: 123 self.question_hash = hashlib.sha256(_tt_hash_str(title).encode('UTF-8')).hexdigest() 124 else: 125 self.question_hash = question_hash 126 self.question_type = QuestionType.TEXT.value 127 128 def to_json(self) -> str: 129 return json.dumps(asdict(self)) 130 131 @classmethod 132 def from_json(cls, json_data: str) -> "QuestionText": 133 d = json.loads(json_data) 134 if isinstance(d.get('title'), dict): 135 d['title'] = TranslatedText(d['title']) 136 if isinstance(d.get('description'), dict): 137 d['description'] = TranslatedText(d['description']) 138 return cls(**d) 139 140 141@dataclass 142class QuestionDummy(Question): 143 """A display-only block (no answer input). Hash derived from title + description.""" 144 145 title: TranslatedText | str 146 description: TranslatedText | str 147 question_hash: str 148 order: int 149 question_type: int = QuestionType.DUMMY.value 150 151 def __init__(self, title, description, question_hash=None, order=None, **kwargs): 152 super().__init__(title, description, question_hash, order, question_type=QuestionType.DUMMY) 153 if question_hash is None: 154 self.question_hash = hashlib.sha256( 155 (_tt_hash_str(title) + '--' + _tt_hash_str(description)).encode('UTF-8') 156 ).hexdigest() 157 else: 158 self.question_hash = question_hash 159 self.question_type = QuestionType.DUMMY.value 160 161 def to_json(self) -> str: 162 return json.dumps(asdict(self)) 163 164 @classmethod 165 def from_json(cls, json_data: str) -> "QuestionDummy": 166 d = json.loads(json_data) 167 if isinstance(d.get('title'), dict): 168 d['title'] = TranslatedText(d['title']) 169 if isinstance(d.get('description'), dict): 170 d['description'] = TranslatedText(d['description']) 171 return cls(**d) 172 173 174@dataclass 175class QuestionForm(Question): 176 """A structured-form question with ``@@{field:spec}@@`` inline markers. 177 178 ``fields`` is a list of dicts, one per marker: ``{"name": str, "regex": str}`` for a 179 single-line text input, ``{"name": str, "lines": int}`` for a multi-line text input 180 (spec ``+`` or ``+N``, see ``form_field_lines()``), ``{"name": str, "choices": [...]}`` for 181 a dropdown and ``{"name": str, "checkbox": bool}`` for a checkbox (its default state). 182 """ 183 184 title: TranslatedText | str 185 description: TranslatedText | str 186 question_hash: str 187 order: int 188 fields: list # [{"name": str, "regex": str}, ...] 189 question_type: int = QuestionType.FORM.value 190 191 def __init__(self, title, description, question_hash=None, order=None, fields=None, **kwargs): 192 super().__init__(title, description, question_hash, order, question_type=QuestionType.FORM) 193 if question_hash is None: 194 self.question_hash = hashlib.sha256( 195 (_tt_hash_str(title) + '--' + _tt_hash_str(description)).encode('UTF-8') 196 ).hexdigest() 197 else: 198 self.question_hash = question_hash 199 self.fields = fields or [] 200 self.question_type = QuestionType.FORM.value 201 202 def to_json(self) -> str: 203 return json.dumps(asdict(self)) 204 205 @classmethod 206 def from_json(cls, json_data: str) -> "QuestionForm": 207 d = json.loads(json_data) 208 if isinstance(d.get('title'), dict): 209 d['title'] = TranslatedText(d['title']) 210 if isinstance(d.get('description'), dict): 211 d['description'] = TranslatedText(d['description']) 212 return cls(**d) 213 214 215FORM_MULTILINE_PREFIX = '+' 216 217 218def form_field_lines(spec: str) -> int | None: 219 """Visible lines of a multi-line form field spec (``+`` or ``+N``), None for any other spec. 220 221 ``+`` and ``+0`` give ``params.default_form_field_lines``; what follows the digits is ignored. 222 The one definition of the grammar, shared by ``Grade0.question_form()`` and the GUI forms. 223 """ 224 if not spec.startswith(FORM_MULTILINE_PREFIX): 225 return None 226 m = re.match(r'\s*(\d*)', spec[len(FORM_MULTILINE_PREFIX):]) 227 return int(m.group(1) or 0) or params.default_form_field_lines 228 229 230@dataclass 231class GradeElement: 232 """One entry in a grade rubric. 233 234 Either numeric (``grade`` / ``max_grade``) or letter-graded (``grade_letter`` ∈ OK/MEH/FAIL). 235 Call ``to_grade_letter()`` to convert a numeric element to its letter equivalent. 236 237 ``scope`` is a bitmask: bit 0 (SELF_EVAL_SCOPE=1) makes the element visible in 238 user-triggered self-eval (``sre eval --auto-eval``); bit 1 (EXO_EVAL_SCOPE=2) 239 makes it visible in non-user-triggered eval, ``sre outline`` and ``sre sheet``. 240 Default 3 (BOTH_EVAL_SCOPE) means visible in both. 241 """ 242 243 title: str 244 max_grade: float | None = None 245 grade: float | None = None 246 grade_letter: str | None = None 247 description: TranslatedText | str = "" 248 scope: int = 3 249 grade_part: str | None = None 250 bonus: bool = False # the grade counts in the totals, the max_grade does not 251 252 def to_dict(self) -> dict: 253 return asdict(self) 254 255 @classmethod 256 def from_dict(cls, d: dict) -> "GradeElement": 257 d = dict(d) 258 if isinstance(d.get('description'), dict): 259 d['description'] = TranslatedText(d['description']) 260 return cls(**d) 261 262 def pack(self) -> bytes: 263 return msgpack.packb(self.to_dict(), use_bin_type=True) 264 265 @classmethod 266 def unpack(cls, data: bytes) -> "GradeElement": 267 return cls.from_dict(msgpack.unpackb(data, raw=False)) 268 269 def to_json(self) -> str: 270 return json.dumps(asdict(self)) 271 272 @classmethod 273 def from_json(cls, s: str) -> "GradeElement": 274 d = json.loads(s) 275 if isinstance(d.get('description'), dict): 276 d['description'] = TranslatedText(d['description']) 277 return cls(**d) 278 279 def to_grade_letter(self) -> "GradeElement": 280 if self.grade == self.max_grade: 281 letter = "OK" 282 elif self.grade is not None and self.grade != 0: 283 letter = "MEH" 284 else: 285 letter = "FAIL" 286 return GradeElement(title=self.title, max_grade=None, grade=None, grade_letter=letter, 287 description=self.description, scope=self.scope, 288 grade_part=self.grade_part, bonus=self.bonus) 289 290 291def bonus_texts(label: str, max_text: str, bonus: bool, in_prefix: bool = False, 292 word: str = "Bonus") -> tuple[str, str]: 293 """The ``(label, max)`` texts of the row of a grade element in a report. 294 295 Unchanged unless *bonus*: then the maximum reads ``"2 (Bonus)"`` (``"(Bonus)"`` when 296 *max_text* is empty, e.g. in letter mode), or, with *in_prefix*, the label reads 297 ``"Bonus: label"`` and the maximum is left untouched (``--bonus-in-prefix`` of 298 ``sre outline`` / ``sre sheet`` / ``sre watch``). *word* is the already translated 299 "Bonus" of the caller (gettext, ``QObject.tr``). 300 """ 301 if not bonus: 302 return label, max_text 303 if in_prefix: 304 return f"{word}: {label}", max_text 305 return label, f"{max_text} ({word})".strip() 306 307 308@dataclass 309class GradePart: 310 """A named group of GradeElements with a shared title and description. 311 312 Lab code registers parts via ``Grade.add_grade_part()`` and associates 313 each :class:`GradeElement` with one via the ``grade_part=`` kwarg on 314 ``add_grade_element``. The element stores the part's *title* (not the 315 object) so it serializes cleanly to JSON / msgpack. 316 """ 317 318 title: str 319 description: TranslatedText | str = "" 320 321 def to_dict(self) -> dict: 322 return asdict(self) 323 324 @classmethod 325 def from_dict(cls, d: dict) -> "GradePart": 326 d = dict(d) 327 if isinstance(d.get('description'), dict): 328 d['description'] = TranslatedText(d['description']) 329 return cls(**d) 330 331 def pack(self) -> bytes: 332 return msgpack.packb(self.to_dict(), use_bin_type=True) 333 334 @classmethod 335 def unpack(cls, data: bytes) -> "GradePart": 336 return cls.from_dict(msgpack.unpackb(data, raw=False)) 337 338 def to_json(self) -> str: 339 return json.dumps(asdict(self)) 340 341 @classmethod 342 def from_json(cls, s: str) -> "GradePart": 343 d = json.loads(s) 344 if isinstance(d.get('description'), dict): 345 d['description'] = TranslatedText(d['description']) 346 return cls(**d) 347 348 349@dataclass 350class InfoInterface: 351 """Network interface descriptor stored inside ``InfoMachine``.""" 352 353 network: str 354 interface_name: str 355 356 def to_json(self) -> str: 357 return json.dumps(asdict(self)) 358 359 @classmethod 360 def from_json(cls, json_data: str) -> "InfoInterface": 361 return cls(**json.loads(json_data)) 362 363 364@dataclass 365class InfoMachine: 366 """Runtime snapshot of a single container, written to ``info.json`` by the CLI and read by the GUI.""" 367 368 name: str 369 status: str 370 allow_connection: bool 371 hidden: bool 372 interfaces: List[InfoInterface] 373 ports: List[str] 374 bridged: bool 375 x11_host: bool = False 376 color: str = "" 377 shape: str = "" 378 379 def to_json(self): 380 return json.dumps(asdict(self)) 381 382 @classmethod 383 def from_json(cls, json_data: str) -> "InfoMachine": 384 return cls(**json.loads(json_data)) 385 386 387@dataclass 388class InfoSwitch: 389 """One network of the lab seen as the switch its machines are plugged into (``switches`` of 390 ``info.json``). ``mode`` is one of ``params.network_modes``; ``allow_connection`` tells 391 whether students may open the console of a managed switch.""" 392 393 name: str 394 mode: str 395 allow_connection: bool = True 396 397 398@dataclass 399class InfoLab: 400 """Full lab metadata written to ``info.json`` at ``sre start`` and polled by the GUI every second. 401 402 ``informations`` (not ``description``) holds the Markdown lab description. 403 ``questions`` is a list of ``QuestionText | QuestionDummy | QuestionForm`` instances. 404 """ 405 406 lab_name: str 407 lab_hash: str 408 title: TranslatedText 409 informations: TranslatedText 410 export_kathara_project: bool 411 allow_self_grade: bool 412 machines: List[InfoMachine] 413 questions: List[Question] 414 delay_between_self_grade: int 415 eval_interval_without_exam_mode: int 416 eval_before_exit: bool 417 user_allowed_states: dict 418 debug_project: bool = False 419 instructor_mode: bool = False 420 allow_save_restore: bool = False 421 export_schema_pdf: bool = True 422 admin_only_states: list = field(default_factory=list) 423 default_language: str = '' 424 network_colors: dict = field(default_factory=dict) 425 network_shapes: dict = field(default_factory=dict) 426 show_nat_network: bool = False 427 nat_network_name: str = '' 428 nat_network_color: str = '' 429 host_network_exploded: bool = False 430 host_network_edge_relative_length: float = 1.0 431 schema_splines: str = 'curved' 432 schema_overlap: str = 'prism' 433 switches: List[InfoSwitch] = field(default_factory=list) 434 435 def to_json(self) -> str: 436 return json.dumps(asdict(self), indent=4) 437 438 @classmethod 439 def from_json(cls, s: str) -> "InfoLab": 440 d = json.loads(s) 441 442 def _wrap_q(item: dict) -> dict: 443 item = dict(item) 444 item['title'] = TranslatedText.from_value(item.get('title', '')) 445 item['description'] = TranslatedText.from_value(item.get('description', '')) 446 return item 447 448 return cls( 449 lab_name=d["lab_name"], 450 lab_hash=d["lab_hash"], 451 title=TranslatedText.from_value(d["title"]), 452 informations=TranslatedText.from_value(d.get("informations", "")), 453 export_kathara_project=d.get("export_kathara_project", True), 454 allow_self_grade=d.get("allow_self_grade", False), 455 debug_project=d.get("debug_project", False), 456 instructor_mode=d.get("instructor_mode", False), 457 allow_save_restore=d.get("allow_save_restore", False), 458 export_schema_pdf=d.get("export_schema_pdf", True), 459 delay_between_self_grade=d.get("delay_between_self_grade", 0), 460 eval_interval_without_exam_mode=d.get("eval_interval_without_exam_mode", 0), 461 eval_before_exit=d.get("eval_before_exit", False), 462 user_allowed_states=d.get("user_allowed_states", {}), 463 admin_only_states=list(d.get("admin_only_states", []) or []), 464 default_language=d.get("default_language", ''), 465 network_colors=d.get("network_colors", {}), 466 network_shapes=d.get("network_shapes", {}), 467 show_nat_network=d.get("show_nat_network", False), 468 nat_network_name=d.get("nat_network_name", ''), 469 nat_network_color=d.get("nat_network_color", ''), 470 host_network_exploded=d.get("host_network_exploded", False), 471 host_network_edge_relative_length=float(d.get("host_network_edge_relative_length", 1.0)), 472 schema_splines=d.get("schema_splines", "curved"), 473 schema_overlap=d.get("schema_overlap", "prism"), 474 machines=[InfoMachine(**item) for item in d["machines"]], 475 switches=[InfoSwitch(**item) for item in d.get("switches", []) or []], 476 questions=[ 477 QuestionDummy(**_wrap_q(item)) if item.get("question_type") == QuestionType.DUMMY.value 478 else QuestionForm(**_wrap_q(item)) if item.get("question_type") == QuestionType.FORM.value 479 else QuestionText(**_wrap_q(item)) 480 for item in d["questions"] 481 ], 482 ) 483 484 485# --------------------------------------------------------------------------------------------- 486# Display helpers shared by the Qt GUI (sysreseval) and the web front-end (sreweb): pure 487# functions of the info.json dict, so that both show a project the same way. 488# --------------------------------------------------------------------------------------------- 489 490#: Identifiers of the tabs of a project, in display order. 491TAB_SCHEMA = "schema" 492TAB_FULL_SCHEMA = "full_schema" 493TAB_INFORMATIONS = "informations" 494TAB_MACHINES = "machines" 495TAB_NETWORKS = "networks" 496TAB_QUESTIONS = "questions" 497TAB_EVALUATION = "evaluation" 498TAB_TERMINALS = "terminals" 499TAB_DISPLAY = "display" # the web only: the virtual X desktop of the project 500TAB_APPLY_CONFIG = "apply_config" 501TAB_LOG = "log" 502TAB_ORDER = (TAB_SCHEMA, TAB_FULL_SCHEMA, TAB_INFORMATIONS, TAB_MACHINES, TAB_NETWORKS, TAB_QUESTIONS, 503 TAB_EVALUATION, TAB_TERMINALS, TAB_DISPLAY, TAB_APPLY_CONFIG, TAB_LOG) 504 505 506def resolve_text(value, lang_priority: list, *, show_instructor: bool = False) -> str: 507 """A text of info.json (a string or a ``{lang: text}`` dict) in the first language of 508 *lang_priority* it has, as plain text: the instructor fragments are removed, or kept without 509 their marks with *show_instructor*.""" 510 text = TranslatedText.from_value(value).resolve_priority(lang_priority) 511 return unwrap_instructor(text) if show_instructor else strip_instructor(text) 512 513 514def format_port(entry: str) -> str: 515 """Format ``host:machine/proto`` of ``InfoMachine.ports`` as ``host -> machine (proto)``.""" 516 proto = "tcp" 517 if "/" in entry: 518 entry, proto = entry.rsplit("/", 1) 519 host_port, _, machine_port = entry.partition(":") 520 return f"{host_port} -> {machine_port} ({proto})" 521 522 523def format_ports(ports: list) -> str: 524 return " - ".join(format_port(p) for p in ports or []) 525 526 527def terminal_devices(info: dict) -> list: 528 """The devices of the Terminals tab: the machines of info.json, then the manageable switches 529 (their management console) as ``{name, allow_connection}`` dicts.""" 530 switches = [{"name": s.get("name", ""), "allow_connection": s.get("allow_connection", False)} 531 for s in info.get("switches", []) or [] 532 if s.get("mode") == params.network_mode_managed] 533 return list(info.get("machines", []) or []) + switches 534 535 536def info_languages(info: dict) -> set: 537 """The language codes of the translated texts of a project (title, informations, questions).""" 538 langs = set() 539 for key in ('title', 'informations'): 540 v = info.get(key) 541 if isinstance(v, dict): 542 langs.update(v.keys()) 543 for q in info.get('questions', []) or []: 544 for field_name in ('title', 'description'): 545 v = q.get(field_name) 546 if isinstance(v, dict): 547 langs.update(v.keys()) 548 return langs 549 550 551def project_title(info: dict, lang_priority: list, fallback: str = '') -> str: 552 """The title of a project tab: ``title`` of info.json in the user's language, else the lab 553 name without ``.py``, else *fallback* (the running lab name).""" 554 raw = info.get("title", info.get("lab_name", fallback).removesuffix('.py')) 555 return TranslatedText.from_value(raw).resolve_priority(lang_priority) or str(raw) 556 557 558def title_sort_key(text: str) -> tuple: 559 """The key of the alphabetical order of the labels of the lab tree (``"@sort": "title"`` in 560 a ``titles.json``): accents and case ignored, the runs of digits compared as numbers ("TP 2" 561 before "TP 10"). The even positions of the tuple hold text, the odd ones integers, so two 562 keys always compare.""" 563 decomposed = unicodedata.normalize('NFKD', text or '') 564 plain = ''.join(c for c in decomposed if not unicodedata.combining(c)).casefold().strip() 565 return tuple(int(part) if i % 2 else part for i, part in enumerate(re.split(r'(\d+)', plain))) 566 567 568def order_tree_entries(children: list, mode: str) -> list: 569 """The children of one directory of the lab tree in display order: the sub-directories first, 570 then the labs; within each group, by label in the ``title`` mode (``title_sort_key``, then 571 ``name``), else in arrival order (``index``: ``sre list --with-titles`` already gives the 572 ``name`` and ``manual`` orders, which the consumers must not sort again). Each child is a 573 dict with ``kind`` (``params.lab_list_kind_dir`` / ``_lab``), ``name``, ``label`` and 574 ``index``; the dicts come back as they are.""" 575 def key(child): 576 group = 0 if child.get('kind') == params.lab_list_kind_dir else 1 577 if mode == params.titles_sort_title: 578 return (group, title_sort_key(child.get('label') or ''), child.get('name') or '') 579 return (group, child.get('index', 0)) 580 return sorted(children, key=key) 581 582 583def listed_states(user_allowed_states: dict, admin_only_states, *, show_admin_only: bool) -> dict: 584 """The states of the Apply Configuration tab: every state of ``user_allowed_states``, or only 585 those a student could apply when *show_admin_only* is false (project in instructor mode while 586 the "Instructor mode" button is off).""" 587 if show_admin_only: 588 return dict(user_allowed_states or {}) 589 admin_only = set(admin_only_states or []) 590 return {state: description for state, description in (user_allowed_states or {}).items() 591 if state not in admin_only} 592 593 594def state_rows(user_allowed_states: dict, admin_only_states, *, show_admin_only: bool, 595 lang_priority: list) -> list: 596 """The rows of the Apply Configuration tab: ``(state, label, admin_only)`` tuples, the label 597 being the description of the state in the user's language, else the state name.""" 598 admin_only = set(admin_only_states or []) 599 rows = [] 600 for state, description in listed_states(user_allowed_states, admin_only_states, 601 show_admin_only=show_admin_only).items(): 602 resolved = (TranslatedText.from_value(description).resolve_priority(lang_priority) 603 if description else '') 604 rows.append((state, resolved if resolved else state, state in admin_only)) 605 return rows 606 607 608def visible_tabs(info: dict, *, exam_mode: bool, instructor_shown: bool, display: bool = False, 609 evaluation: bool | None = None) -> list: 610 """The tabs shown for a project, in display order (the ``TAB_*`` identifiers). 611 612 *instructor_shown*: the project has instructor texts (instructor mode or debug project) and 613 the "Instructor mode" button is on. A debug project always gets the Full Schema, every state 614 and the Log; the Evaluation tab is hidden in exam mode and when the lab does not allow 615 self-evaluation (*evaluation* forces it on or off whatever the lab says: the web shows an 616 instructor the last archived evaluation of any project); the Apply Configuration tab needs 617 listed states (and not an exam, unless the instructor view is on). *display*: the web only, 618 the Display tab of the virtual X desktop (after the Terminals); the Qt GUI never asks for it.""" 619 debug = bool(info.get("debug_project", False)) 620 if evaluation is None: 621 evaluation = bool(info.get("allow_self_grade", True)) 622 tabs = [TAB_SCHEMA] 623 if debug: 624 tabs.append(TAB_FULL_SCHEMA) 625 tabs += [TAB_INFORMATIONS, TAB_MACHINES, TAB_NETWORKS] 626 if info.get("questions"): 627 tabs.append(TAB_QUESTIONS) 628 if not exam_mode and evaluation: 629 tabs.append(TAB_EVALUATION) 630 tabs.append(TAB_TERMINALS) 631 if display: 632 tabs.append(TAB_DISPLAY) 633 states = listed_states(info.get("user_allowed_states", {}), info.get("admin_only_states", []), 634 show_admin_only=debug or instructor_shown) 635 if debug or ((instructor_shown or not exam_mode) and bool(states)): 636 tabs.append(TAB_APPLY_CONFIG) 637 if debug or instructor_shown: 638 tabs.append(TAB_LOG) 639 return tabs 640 641 642# --------------------------------------------------------------------------- 643# Project ownership (the user mode of the CLI, the project list of the GUI) 644# --------------------------------------------------------------------------- 645 646def project_accessible(running_lab_name: str, username: str | None) -> bool: 647 """Whether *username*, in user mode, may see and act on the project *running_lab_name* 648 (:func:`SRE.access.may_access_project` with ``params.check_project_owner``: their own 649 projects and those of root or the sre user, every project when the check is off). 650 Privileged callers are never asked: the CLI calls this in user mode only, the GUI always 651 (it goes through sre-wrapper).""" 652 return access.may_access_project(params.get_username_from_running_lab_name(running_lab_name), 653 username or '', check_owner=params.check_project_owner, 654 shared_owners=('root', params.sre_user))
20def icon_node_attrs(image: str, color: str | None, width: str, height: str) -> dict: 21 """Graphviz attributes of a network drawn as an icon with its label below: a box filled with 22 its colour behind the image when it has one, nothing otherwise.""" 23 attrs = {"image": image, "labelloc": "b", "width": width, "height": height, 24 "fixedsize": "true", "imagescale": "true", "fontsize": "16"} 25 if color: 26 attrs.update(shape="box", style="filled", fillcolor=color) 27 else: 28 attrs["shape"] = "none" 29 return attrs
Graphviz attributes of a network drawn as an icon with its label below: a box filled with its colour behind the image when it has one, nothing otherwise.
32class TranslatedText(dict): 33 """A dict subclass holding translations keyed by language code (e.g. 'en', 'fr'). 34 35 Use ``resolve(lang)`` to get the text for a specific language, falling back to the 36 first available translation. ``from_value`` ensures backward-compat with plain strings. 37 """ 38 39 def resolve(self, lang: str, fallback: str = '') -> str: 40 return self.get(lang) or next(iter(self.values()), fallback) 41 42 def resolve_priority(self, langs: list, fallback: str = '') -> str: 43 """Return the first translation found in the priority list, or first available.""" 44 for lang in langs: 45 if self.get(lang): 46 return self[lang] 47 return next(iter(self.values()), fallback) 48 49 def __add__(self, other) -> 'TranslatedText': 50 if isinstance(other, str): 51 return TranslatedText({lang: self[lang] + other for lang in self}) 52 if not isinstance(other, TranslatedText): 53 return NotImplemented 54 if not self: 55 return TranslatedText(other) 56 if not other: 57 return TranslatedText(self) 58 # Merge mismatched language sets gracefully (partial translations): take 59 # the union of languages, falling back to each side's default-language 60 # (first-key) value for a language that side lacks. 61 self_default = next(iter(self.values())) 62 other_default = next(iter(other.values())) 63 langs = list(dict.fromkeys((*self, *other))) 64 return TranslatedText({ 65 lang: self.get(lang, self_default) + other.get(lang, other_default) 66 for lang in langs 67 }) 68 69 def __radd__(self, other) -> 'TranslatedText': 70 if isinstance(other, str): 71 return TranslatedText({lang: other + self[lang] for lang in self}) 72 return NotImplemented 73 74 def format(self, **kwargs) -> 'TranslatedText': 75 """Apply str.format(**kwargs) to every language value.""" 76 return TranslatedText({lang: text.format(**kwargs) for lang, text in self.items()}) 77 78 @classmethod 79 def from_value(cls, v, default_lang: str = 'en') -> 'TranslatedText': 80 if isinstance(v, cls): 81 return v 82 if isinstance(v, dict): 83 return cls(v) 84 return cls({default_lang: str(v)}) if v else cls()
A dict subclass holding translations keyed by language code (e.g. 'en', 'fr').
Use resolve(lang) to get the text for a specific language, falling back to the
first available translation. from_value ensures backward-compat with plain strings.
42 def resolve_priority(self, langs: list, fallback: str = '') -> str: 43 """Return the first translation found in the priority list, or first available.""" 44 for lang in langs: 45 if self.get(lang): 46 return self[lang] 47 return next(iter(self.values()), fallback)
Return the first translation found in the priority list, or first available.
74 def format(self, **kwargs) -> 'TranslatedText': 75 """Apply str.format(**kwargs) to every language value.""" 76 return TranslatedText({lang: text.format(**kwargs) for lang, text in self.items()})
Apply str.format(**kwargs) to every language value.
99class Question: 100 """Abstract base for all question types (TEXT, FORM, DUMMY).""" 101 102 def __init__(self, title, description, question_hash, order, question_type): 103 self.title = title 104 self.description = description 105 self.question_hash = question_hash 106 self.order = order 107 self.question_type = question_type
Abstract base for all question types (TEXT, FORM, DUMMY).
111@dataclass 112class QuestionText(Question): 113 """A free-text answer question. Hash is derived from *title* if not provided.""" 114 115 title: TranslatedText | str 116 description: TranslatedText | str 117 question_hash: str 118 order: int 119 question_type: int = QuestionType.TEXT.value 120 121 def __init__(self, title, description='', question_hash=None, order=None, **kwargs): 122 super().__init__(title, description, question_hash, order, question_type=QuestionType.TEXT) 123 if question_hash is None: 124 self.question_hash = hashlib.sha256(_tt_hash_str(title).encode('UTF-8')).hexdigest() 125 else: 126 self.question_hash = question_hash 127 self.question_type = QuestionType.TEXT.value 128 129 def to_json(self) -> str: 130 return json.dumps(asdict(self)) 131 132 @classmethod 133 def from_json(cls, json_data: str) -> "QuestionText": 134 d = json.loads(json_data) 135 if isinstance(d.get('title'), dict): 136 d['title'] = TranslatedText(d['title']) 137 if isinstance(d.get('description'), dict): 138 d['description'] = TranslatedText(d['description']) 139 return cls(**d)
A free-text answer question. Hash is derived from title if not provided.
121 def __init__(self, title, description='', question_hash=None, order=None, **kwargs): 122 super().__init__(title, description, question_hash, order, question_type=QuestionType.TEXT) 123 if question_hash is None: 124 self.question_hash = hashlib.sha256(_tt_hash_str(title).encode('UTF-8')).hexdigest() 125 else: 126 self.question_hash = question_hash 127 self.question_type = QuestionType.TEXT.value
132 @classmethod 133 def from_json(cls, json_data: str) -> "QuestionText": 134 d = json.loads(json_data) 135 if isinstance(d.get('title'), dict): 136 d['title'] = TranslatedText(d['title']) 137 if isinstance(d.get('description'), dict): 138 d['description'] = TranslatedText(d['description']) 139 return cls(**d)
142@dataclass 143class QuestionDummy(Question): 144 """A display-only block (no answer input). Hash derived from title + description.""" 145 146 title: TranslatedText | str 147 description: TranslatedText | str 148 question_hash: str 149 order: int 150 question_type: int = QuestionType.DUMMY.value 151 152 def __init__(self, title, description, question_hash=None, order=None, **kwargs): 153 super().__init__(title, description, question_hash, order, question_type=QuestionType.DUMMY) 154 if question_hash is None: 155 self.question_hash = hashlib.sha256( 156 (_tt_hash_str(title) + '--' + _tt_hash_str(description)).encode('UTF-8') 157 ).hexdigest() 158 else: 159 self.question_hash = question_hash 160 self.question_type = QuestionType.DUMMY.value 161 162 def to_json(self) -> str: 163 return json.dumps(asdict(self)) 164 165 @classmethod 166 def from_json(cls, json_data: str) -> "QuestionDummy": 167 d = json.loads(json_data) 168 if isinstance(d.get('title'), dict): 169 d['title'] = TranslatedText(d['title']) 170 if isinstance(d.get('description'), dict): 171 d['description'] = TranslatedText(d['description']) 172 return cls(**d)
A display-only block (no answer input). Hash derived from title + description.
152 def __init__(self, title, description, question_hash=None, order=None, **kwargs): 153 super().__init__(title, description, question_hash, order, question_type=QuestionType.DUMMY) 154 if question_hash is None: 155 self.question_hash = hashlib.sha256( 156 (_tt_hash_str(title) + '--' + _tt_hash_str(description)).encode('UTF-8') 157 ).hexdigest() 158 else: 159 self.question_hash = question_hash 160 self.question_type = QuestionType.DUMMY.value
165 @classmethod 166 def from_json(cls, json_data: str) -> "QuestionDummy": 167 d = json.loads(json_data) 168 if isinstance(d.get('title'), dict): 169 d['title'] = TranslatedText(d['title']) 170 if isinstance(d.get('description'), dict): 171 d['description'] = TranslatedText(d['description']) 172 return cls(**d)
175@dataclass 176class QuestionForm(Question): 177 """A structured-form question with ``@@{field:spec}@@`` inline markers. 178 179 ``fields`` is a list of dicts, one per marker: ``{"name": str, "regex": str}`` for a 180 single-line text input, ``{"name": str, "lines": int}`` for a multi-line text input 181 (spec ``+`` or ``+N``, see ``form_field_lines()``), ``{"name": str, "choices": [...]}`` for 182 a dropdown and ``{"name": str, "checkbox": bool}`` for a checkbox (its default state). 183 """ 184 185 title: TranslatedText | str 186 description: TranslatedText | str 187 question_hash: str 188 order: int 189 fields: list # [{"name": str, "regex": str}, ...] 190 question_type: int = QuestionType.FORM.value 191 192 def __init__(self, title, description, question_hash=None, order=None, fields=None, **kwargs): 193 super().__init__(title, description, question_hash, order, question_type=QuestionType.FORM) 194 if question_hash is None: 195 self.question_hash = hashlib.sha256( 196 (_tt_hash_str(title) + '--' + _tt_hash_str(description)).encode('UTF-8') 197 ).hexdigest() 198 else: 199 self.question_hash = question_hash 200 self.fields = fields or [] 201 self.question_type = QuestionType.FORM.value 202 203 def to_json(self) -> str: 204 return json.dumps(asdict(self)) 205 206 @classmethod 207 def from_json(cls, json_data: str) -> "QuestionForm": 208 d = json.loads(json_data) 209 if isinstance(d.get('title'), dict): 210 d['title'] = TranslatedText(d['title']) 211 if isinstance(d.get('description'), dict): 212 d['description'] = TranslatedText(d['description']) 213 return cls(**d)
A structured-form question with @@{field:spec}@@ inline markers.
fields is a list of dicts, one per marker: {"name": str, "regex": str} for a
single-line text input, {"name": str, "lines": int} for a multi-line text input
(spec + or +N, see form_field_lines()), {"name": str, "choices": [...]} for
a dropdown and {"name": str, "checkbox": bool} for a checkbox (its default state).
192 def __init__(self, title, description, question_hash=None, order=None, fields=None, **kwargs): 193 super().__init__(title, description, question_hash, order, question_type=QuestionType.FORM) 194 if question_hash is None: 195 self.question_hash = hashlib.sha256( 196 (_tt_hash_str(title) + '--' + _tt_hash_str(description)).encode('UTF-8') 197 ).hexdigest() 198 else: 199 self.question_hash = question_hash 200 self.fields = fields or [] 201 self.question_type = QuestionType.FORM.value
206 @classmethod 207 def from_json(cls, json_data: str) -> "QuestionForm": 208 d = json.loads(json_data) 209 if isinstance(d.get('title'), dict): 210 d['title'] = TranslatedText(d['title']) 211 if isinstance(d.get('description'), dict): 212 d['description'] = TranslatedText(d['description']) 213 return cls(**d)
219def form_field_lines(spec: str) -> int | None: 220 """Visible lines of a multi-line form field spec (``+`` or ``+N``), None for any other spec. 221 222 ``+`` and ``+0`` give ``params.default_form_field_lines``; what follows the digits is ignored. 223 The one definition of the grammar, shared by ``Grade0.question_form()`` and the GUI forms. 224 """ 225 if not spec.startswith(FORM_MULTILINE_PREFIX): 226 return None 227 m = re.match(r'\s*(\d*)', spec[len(FORM_MULTILINE_PREFIX):]) 228 return int(m.group(1) or 0) or params.default_form_field_lines
Visible lines of a multi-line form field spec (+ or +N), None for any other spec.
+ and +0 give params.default_form_field_lines; what follows the digits is ignored.
The one definition of the grammar, shared by Grade0.question_form() and the GUI forms.
231@dataclass 232class GradeElement: 233 """One entry in a grade rubric. 234 235 Either numeric (``grade`` / ``max_grade``) or letter-graded (``grade_letter`` ∈ OK/MEH/FAIL). 236 Call ``to_grade_letter()`` to convert a numeric element to its letter equivalent. 237 238 ``scope`` is a bitmask: bit 0 (SELF_EVAL_SCOPE=1) makes the element visible in 239 user-triggered self-eval (``sre eval --auto-eval``); bit 1 (EXO_EVAL_SCOPE=2) 240 makes it visible in non-user-triggered eval, ``sre outline`` and ``sre sheet``. 241 Default 3 (BOTH_EVAL_SCOPE) means visible in both. 242 """ 243 244 title: str 245 max_grade: float | None = None 246 grade: float | None = None 247 grade_letter: str | None = None 248 description: TranslatedText | str = "" 249 scope: int = 3 250 grade_part: str | None = None 251 bonus: bool = False # the grade counts in the totals, the max_grade does not 252 253 def to_dict(self) -> dict: 254 return asdict(self) 255 256 @classmethod 257 def from_dict(cls, d: dict) -> "GradeElement": 258 d = dict(d) 259 if isinstance(d.get('description'), dict): 260 d['description'] = TranslatedText(d['description']) 261 return cls(**d) 262 263 def pack(self) -> bytes: 264 return msgpack.packb(self.to_dict(), use_bin_type=True) 265 266 @classmethod 267 def unpack(cls, data: bytes) -> "GradeElement": 268 return cls.from_dict(msgpack.unpackb(data, raw=False)) 269 270 def to_json(self) -> str: 271 return json.dumps(asdict(self)) 272 273 @classmethod 274 def from_json(cls, s: str) -> "GradeElement": 275 d = json.loads(s) 276 if isinstance(d.get('description'), dict): 277 d['description'] = TranslatedText(d['description']) 278 return cls(**d) 279 280 def to_grade_letter(self) -> "GradeElement": 281 if self.grade == self.max_grade: 282 letter = "OK" 283 elif self.grade is not None and self.grade != 0: 284 letter = "MEH" 285 else: 286 letter = "FAIL" 287 return GradeElement(title=self.title, max_grade=None, grade=None, grade_letter=letter, 288 description=self.description, scope=self.scope, 289 grade_part=self.grade_part, bonus=self.bonus)
One entry in a grade rubric.
Either numeric (grade / max_grade) or letter-graded (grade_letter ∈ OK/MEH/FAIL).
Call to_grade_letter() to convert a numeric element to its letter equivalent.
scope is a bitmask: bit 0 (SELF_EVAL_SCOPE=1) makes the element visible in
user-triggered self-eval (sre eval --auto-eval); bit 1 (EXO_EVAL_SCOPE=2)
makes it visible in non-user-triggered eval, sre outline and sre sheet.
Default 3 (BOTH_EVAL_SCOPE) means visible in both.
280 def to_grade_letter(self) -> "GradeElement": 281 if self.grade == self.max_grade: 282 letter = "OK" 283 elif self.grade is not None and self.grade != 0: 284 letter = "MEH" 285 else: 286 letter = "FAIL" 287 return GradeElement(title=self.title, max_grade=None, grade=None, grade_letter=letter, 288 description=self.description, scope=self.scope, 289 grade_part=self.grade_part, bonus=self.bonus)
292def bonus_texts(label: str, max_text: str, bonus: bool, in_prefix: bool = False, 293 word: str = "Bonus") -> tuple[str, str]: 294 """The ``(label, max)`` texts of the row of a grade element in a report. 295 296 Unchanged unless *bonus*: then the maximum reads ``"2 (Bonus)"`` (``"(Bonus)"`` when 297 *max_text* is empty, e.g. in letter mode), or, with *in_prefix*, the label reads 298 ``"Bonus: label"`` and the maximum is left untouched (``--bonus-in-prefix`` of 299 ``sre outline`` / ``sre sheet`` / ``sre watch``). *word* is the already translated 300 "Bonus" of the caller (gettext, ``QObject.tr``). 301 """ 302 if not bonus: 303 return label, max_text 304 if in_prefix: 305 return f"{word}: {label}", max_text 306 return label, f"{max_text} ({word})".strip()
The (label, max) texts of the row of a grade element in a report.
Unchanged unless bonus: then the maximum reads "2 (Bonus)" ("(Bonus)" when
max_text is empty, e.g. in letter mode), or, with in_prefix, the label reads
"Bonus: label" and the maximum is left untouched (--bonus-in-prefix of
sre outline / sre sheet / sre watch). word is the already translated
"Bonus" of the caller (gettext, QObject.tr).
309@dataclass 310class GradePart: 311 """A named group of GradeElements with a shared title and description. 312 313 Lab code registers parts via ``Grade.add_grade_part()`` and associates 314 each :class:`GradeElement` with one via the ``grade_part=`` kwarg on 315 ``add_grade_element``. The element stores the part's *title* (not the 316 object) so it serializes cleanly to JSON / msgpack. 317 """ 318 319 title: str 320 description: TranslatedText | str = "" 321 322 def to_dict(self) -> dict: 323 return asdict(self) 324 325 @classmethod 326 def from_dict(cls, d: dict) -> "GradePart": 327 d = dict(d) 328 if isinstance(d.get('description'), dict): 329 d['description'] = TranslatedText(d['description']) 330 return cls(**d) 331 332 def pack(self) -> bytes: 333 return msgpack.packb(self.to_dict(), use_bin_type=True) 334 335 @classmethod 336 def unpack(cls, data: bytes) -> "GradePart": 337 return cls.from_dict(msgpack.unpackb(data, raw=False)) 338 339 def to_json(self) -> str: 340 return json.dumps(asdict(self)) 341 342 @classmethod 343 def from_json(cls, s: str) -> "GradePart": 344 d = json.loads(s) 345 if isinstance(d.get('description'), dict): 346 d['description'] = TranslatedText(d['description']) 347 return cls(**d)
A named group of GradeElements with a shared title and description.
Lab code registers parts via Grade.add_grade_part() and associates
each GradeElement with one via the grade_part= kwarg on
add_grade_element. The element stores the part's title (not the
object) so it serializes cleanly to JSON / msgpack.
350@dataclass 351class InfoInterface: 352 """Network interface descriptor stored inside ``InfoMachine``.""" 353 354 network: str 355 interface_name: str 356 357 def to_json(self) -> str: 358 return json.dumps(asdict(self)) 359 360 @classmethod 361 def from_json(cls, json_data: str) -> "InfoInterface": 362 return cls(**json.loads(json_data))
Network interface descriptor stored inside InfoMachine.
365@dataclass 366class InfoMachine: 367 """Runtime snapshot of a single container, written to ``info.json`` by the CLI and read by the GUI.""" 368 369 name: str 370 status: str 371 allow_connection: bool 372 hidden: bool 373 interfaces: List[InfoInterface] 374 ports: List[str] 375 bridged: bool 376 x11_host: bool = False 377 color: str = "" 378 shape: str = "" 379 380 def to_json(self): 381 return json.dumps(asdict(self)) 382 383 @classmethod 384 def from_json(cls, json_data: str) -> "InfoMachine": 385 return cls(**json.loads(json_data))
Runtime snapshot of a single container, written to info.json by the CLI and read by the GUI.
388@dataclass 389class InfoSwitch: 390 """One network of the lab seen as the switch its machines are plugged into (``switches`` of 391 ``info.json``). ``mode`` is one of ``params.network_modes``; ``allow_connection`` tells 392 whether students may open the console of a managed switch.""" 393 394 name: str 395 mode: str 396 allow_connection: bool = True
One network of the lab seen as the switch its machines are plugged into (switches of
info.json). mode is one of params.network_modes; allow_connection tells
whether students may open the console of a managed switch.
399@dataclass 400class InfoLab: 401 """Full lab metadata written to ``info.json`` at ``sre start`` and polled by the GUI every second. 402 403 ``informations`` (not ``description``) holds the Markdown lab description. 404 ``questions`` is a list of ``QuestionText | QuestionDummy | QuestionForm`` instances. 405 """ 406 407 lab_name: str 408 lab_hash: str 409 title: TranslatedText 410 informations: TranslatedText 411 export_kathara_project: bool 412 allow_self_grade: bool 413 machines: List[InfoMachine] 414 questions: List[Question] 415 delay_between_self_grade: int 416 eval_interval_without_exam_mode: int 417 eval_before_exit: bool 418 user_allowed_states: dict 419 debug_project: bool = False 420 instructor_mode: bool = False 421 allow_save_restore: bool = False 422 export_schema_pdf: bool = True 423 admin_only_states: list = field(default_factory=list) 424 default_language: str = '' 425 network_colors: dict = field(default_factory=dict) 426 network_shapes: dict = field(default_factory=dict) 427 show_nat_network: bool = False 428 nat_network_name: str = '' 429 nat_network_color: str = '' 430 host_network_exploded: bool = False 431 host_network_edge_relative_length: float = 1.0 432 schema_splines: str = 'curved' 433 schema_overlap: str = 'prism' 434 switches: List[InfoSwitch] = field(default_factory=list) 435 436 def to_json(self) -> str: 437 return json.dumps(asdict(self), indent=4) 438 439 @classmethod 440 def from_json(cls, s: str) -> "InfoLab": 441 d = json.loads(s) 442 443 def _wrap_q(item: dict) -> dict: 444 item = dict(item) 445 item['title'] = TranslatedText.from_value(item.get('title', '')) 446 item['description'] = TranslatedText.from_value(item.get('description', '')) 447 return item 448 449 return cls( 450 lab_name=d["lab_name"], 451 lab_hash=d["lab_hash"], 452 title=TranslatedText.from_value(d["title"]), 453 informations=TranslatedText.from_value(d.get("informations", "")), 454 export_kathara_project=d.get("export_kathara_project", True), 455 allow_self_grade=d.get("allow_self_grade", False), 456 debug_project=d.get("debug_project", False), 457 instructor_mode=d.get("instructor_mode", False), 458 allow_save_restore=d.get("allow_save_restore", False), 459 export_schema_pdf=d.get("export_schema_pdf", True), 460 delay_between_self_grade=d.get("delay_between_self_grade", 0), 461 eval_interval_without_exam_mode=d.get("eval_interval_without_exam_mode", 0), 462 eval_before_exit=d.get("eval_before_exit", False), 463 user_allowed_states=d.get("user_allowed_states", {}), 464 admin_only_states=list(d.get("admin_only_states", []) or []), 465 default_language=d.get("default_language", ''), 466 network_colors=d.get("network_colors", {}), 467 network_shapes=d.get("network_shapes", {}), 468 show_nat_network=d.get("show_nat_network", False), 469 nat_network_name=d.get("nat_network_name", ''), 470 nat_network_color=d.get("nat_network_color", ''), 471 host_network_exploded=d.get("host_network_exploded", False), 472 host_network_edge_relative_length=float(d.get("host_network_edge_relative_length", 1.0)), 473 schema_splines=d.get("schema_splines", "curved"), 474 schema_overlap=d.get("schema_overlap", "prism"), 475 machines=[InfoMachine(**item) for item in d["machines"]], 476 switches=[InfoSwitch(**item) for item in d.get("switches", []) or []], 477 questions=[ 478 QuestionDummy(**_wrap_q(item)) if item.get("question_type") == QuestionType.DUMMY.value 479 else QuestionForm(**_wrap_q(item)) if item.get("question_type") == QuestionType.FORM.value 480 else QuestionText(**_wrap_q(item)) 481 for item in d["questions"] 482 ], 483 )
Full lab metadata written to info.json at sre start and polled by the GUI every second.
informations (not description) holds the Markdown lab description.
questions is a list of QuestionText | QuestionDummy | QuestionForm instances.
439 @classmethod 440 def from_json(cls, s: str) -> "InfoLab": 441 d = json.loads(s) 442 443 def _wrap_q(item: dict) -> dict: 444 item = dict(item) 445 item['title'] = TranslatedText.from_value(item.get('title', '')) 446 item['description'] = TranslatedText.from_value(item.get('description', '')) 447 return item 448 449 return cls( 450 lab_name=d["lab_name"], 451 lab_hash=d["lab_hash"], 452 title=TranslatedText.from_value(d["title"]), 453 informations=TranslatedText.from_value(d.get("informations", "")), 454 export_kathara_project=d.get("export_kathara_project", True), 455 allow_self_grade=d.get("allow_self_grade", False), 456 debug_project=d.get("debug_project", False), 457 instructor_mode=d.get("instructor_mode", False), 458 allow_save_restore=d.get("allow_save_restore", False), 459 export_schema_pdf=d.get("export_schema_pdf", True), 460 delay_between_self_grade=d.get("delay_between_self_grade", 0), 461 eval_interval_without_exam_mode=d.get("eval_interval_without_exam_mode", 0), 462 eval_before_exit=d.get("eval_before_exit", False), 463 user_allowed_states=d.get("user_allowed_states", {}), 464 admin_only_states=list(d.get("admin_only_states", []) or []), 465 default_language=d.get("default_language", ''), 466 network_colors=d.get("network_colors", {}), 467 network_shapes=d.get("network_shapes", {}), 468 show_nat_network=d.get("show_nat_network", False), 469 nat_network_name=d.get("nat_network_name", ''), 470 nat_network_color=d.get("nat_network_color", ''), 471 host_network_exploded=d.get("host_network_exploded", False), 472 host_network_edge_relative_length=float(d.get("host_network_edge_relative_length", 1.0)), 473 schema_splines=d.get("schema_splines", "curved"), 474 schema_overlap=d.get("schema_overlap", "prism"), 475 machines=[InfoMachine(**item) for item in d["machines"]], 476 switches=[InfoSwitch(**item) for item in d.get("switches", []) or []], 477 questions=[ 478 QuestionDummy(**_wrap_q(item)) if item.get("question_type") == QuestionType.DUMMY.value 479 else QuestionForm(**_wrap_q(item)) if item.get("question_type") == QuestionType.FORM.value 480 else QuestionText(**_wrap_q(item)) 481 for item in d["questions"] 482 ], 483 )
507def resolve_text(value, lang_priority: list, *, show_instructor: bool = False) -> str: 508 """A text of info.json (a string or a ``{lang: text}`` dict) in the first language of 509 *lang_priority* it has, as plain text: the instructor fragments are removed, or kept without 510 their marks with *show_instructor*.""" 511 text = TranslatedText.from_value(value).resolve_priority(lang_priority) 512 return unwrap_instructor(text) if show_instructor else strip_instructor(text)
A text of info.json (a string or a {lang: text} dict) in the first language of
lang_priority it has, as plain text: the instructor fragments are removed, or kept without
their marks with show_instructor.
515def format_port(entry: str) -> str: 516 """Format ``host:machine/proto`` of ``InfoMachine.ports`` as ``host -> machine (proto)``.""" 517 proto = "tcp" 518 if "/" in entry: 519 entry, proto = entry.rsplit("/", 1) 520 host_port, _, machine_port = entry.partition(":") 521 return f"{host_port} -> {machine_port} ({proto})"
Format host:machine/proto of InfoMachine.ports as host -> machine (proto).
528def terminal_devices(info: dict) -> list: 529 """The devices of the Terminals tab: the machines of info.json, then the manageable switches 530 (their management console) as ``{name, allow_connection}`` dicts.""" 531 switches = [{"name": s.get("name", ""), "allow_connection": s.get("allow_connection", False)} 532 for s in info.get("switches", []) or [] 533 if s.get("mode") == params.network_mode_managed] 534 return list(info.get("machines", []) or []) + switches
The devices of the Terminals tab: the machines of info.json, then the manageable switches
(their management console) as {name, allow_connection} dicts.
537def info_languages(info: dict) -> set: 538 """The language codes of the translated texts of a project (title, informations, questions).""" 539 langs = set() 540 for key in ('title', 'informations'): 541 v = info.get(key) 542 if isinstance(v, dict): 543 langs.update(v.keys()) 544 for q in info.get('questions', []) or []: 545 for field_name in ('title', 'description'): 546 v = q.get(field_name) 547 if isinstance(v, dict): 548 langs.update(v.keys()) 549 return langs
The language codes of the translated texts of a project (title, informations, questions).
552def project_title(info: dict, lang_priority: list, fallback: str = '') -> str: 553 """The title of a project tab: ``title`` of info.json in the user's language, else the lab 554 name without ``.py``, else *fallback* (the running lab name).""" 555 raw = info.get("title", info.get("lab_name", fallback).removesuffix('.py')) 556 return TranslatedText.from_value(raw).resolve_priority(lang_priority) or str(raw)
The title of a project tab: title of info.json in the user's language, else the lab
name without .py, else fallback (the running lab name).
559def title_sort_key(text: str) -> tuple: 560 """The key of the alphabetical order of the labels of the lab tree (``"@sort": "title"`` in 561 a ``titles.json``): accents and case ignored, the runs of digits compared as numbers ("TP 2" 562 before "TP 10"). The even positions of the tuple hold text, the odd ones integers, so two 563 keys always compare.""" 564 decomposed = unicodedata.normalize('NFKD', text or '') 565 plain = ''.join(c for c in decomposed if not unicodedata.combining(c)).casefold().strip() 566 return tuple(int(part) if i % 2 else part for i, part in enumerate(re.split(r'(\d+)', plain)))
The key of the alphabetical order of the labels of the lab tree ("@sort": "title" in
a titles.json): accents and case ignored, the runs of digits compared as numbers ("TP 2"
before "TP 10"). The even positions of the tuple hold text, the odd ones integers, so two
keys always compare.
569def order_tree_entries(children: list, mode: str) -> list: 570 """The children of one directory of the lab tree in display order: the sub-directories first, 571 then the labs; within each group, by label in the ``title`` mode (``title_sort_key``, then 572 ``name``), else in arrival order (``index``: ``sre list --with-titles`` already gives the 573 ``name`` and ``manual`` orders, which the consumers must not sort again). Each child is a 574 dict with ``kind`` (``params.lab_list_kind_dir`` / ``_lab``), ``name``, ``label`` and 575 ``index``; the dicts come back as they are.""" 576 def key(child): 577 group = 0 if child.get('kind') == params.lab_list_kind_dir else 1 578 if mode == params.titles_sort_title: 579 return (group, title_sort_key(child.get('label') or ''), child.get('name') or '') 580 return (group, child.get('index', 0)) 581 return sorted(children, key=key)
The children of one directory of the lab tree in display order: the sub-directories first,
then the labs; within each group, by label in the title mode (title_sort_key, then
name), else in arrival order (index: sre list --with-titles already gives the
name and manual orders, which the consumers must not sort again). Each child is a
dict with kind (params.lab_list_kind_dir / _lab), name, label and
index; the dicts come back as they are.
584def listed_states(user_allowed_states: dict, admin_only_states, *, show_admin_only: bool) -> dict: 585 """The states of the Apply Configuration tab: every state of ``user_allowed_states``, or only 586 those a student could apply when *show_admin_only* is false (project in instructor mode while 587 the "Instructor mode" button is off).""" 588 if show_admin_only: 589 return dict(user_allowed_states or {}) 590 admin_only = set(admin_only_states or []) 591 return {state: description for state, description in (user_allowed_states or {}).items() 592 if state not in admin_only}
The states of the Apply Configuration tab: every state of user_allowed_states, or only
those a student could apply when show_admin_only is false (project in instructor mode while
the "Instructor mode" button is off).
595def state_rows(user_allowed_states: dict, admin_only_states, *, show_admin_only: bool, 596 lang_priority: list) -> list: 597 """The rows of the Apply Configuration tab: ``(state, label, admin_only)`` tuples, the label 598 being the description of the state in the user's language, else the state name.""" 599 admin_only = set(admin_only_states or []) 600 rows = [] 601 for state, description in listed_states(user_allowed_states, admin_only_states, 602 show_admin_only=show_admin_only).items(): 603 resolved = (TranslatedText.from_value(description).resolve_priority(lang_priority) 604 if description else '') 605 rows.append((state, resolved if resolved else state, state in admin_only)) 606 return rows
The rows of the Apply Configuration tab: (state, label, admin_only) tuples, the label
being the description of the state in the user's language, else the state name.
609def visible_tabs(info: dict, *, exam_mode: bool, instructor_shown: bool, display: bool = False, 610 evaluation: bool | None = None) -> list: 611 """The tabs shown for a project, in display order (the ``TAB_*`` identifiers). 612 613 *instructor_shown*: the project has instructor texts (instructor mode or debug project) and 614 the "Instructor mode" button is on. A debug project always gets the Full Schema, every state 615 and the Log; the Evaluation tab is hidden in exam mode and when the lab does not allow 616 self-evaluation (*evaluation* forces it on or off whatever the lab says: the web shows an 617 instructor the last archived evaluation of any project); the Apply Configuration tab needs 618 listed states (and not an exam, unless the instructor view is on). *display*: the web only, 619 the Display tab of the virtual X desktop (after the Terminals); the Qt GUI never asks for it.""" 620 debug = bool(info.get("debug_project", False)) 621 if evaluation is None: 622 evaluation = bool(info.get("allow_self_grade", True)) 623 tabs = [TAB_SCHEMA] 624 if debug: 625 tabs.append(TAB_FULL_SCHEMA) 626 tabs += [TAB_INFORMATIONS, TAB_MACHINES, TAB_NETWORKS] 627 if info.get("questions"): 628 tabs.append(TAB_QUESTIONS) 629 if not exam_mode and evaluation: 630 tabs.append(TAB_EVALUATION) 631 tabs.append(TAB_TERMINALS) 632 if display: 633 tabs.append(TAB_DISPLAY) 634 states = listed_states(info.get("user_allowed_states", {}), info.get("admin_only_states", []), 635 show_admin_only=debug or instructor_shown) 636 if debug or ((instructor_shown or not exam_mode) and bool(states)): 637 tabs.append(TAB_APPLY_CONFIG) 638 if debug or instructor_shown: 639 tabs.append(TAB_LOG) 640 return tabs
The tabs shown for a project, in display order (the TAB_* identifiers).
instructor_shown: the project has instructor texts (instructor mode or debug project) and the "Instructor mode" button is on. A debug project always gets the Full Schema, every state and the Log; the Evaluation tab is hidden in exam mode and when the lab does not allow self-evaluation (evaluation forces it on or off whatever the lab says: the web shows an instructor the last archived evaluation of any project); the Apply Configuration tab needs listed states (and not an exam, unless the instructor view is on). display: the web only, the Display tab of the virtual X desktop (after the Terminals); the Qt GUI never asks for it.
647def project_accessible(running_lab_name: str, username: str | None) -> bool: 648 """Whether *username*, in user mode, may see and act on the project *running_lab_name* 649 (:func:`SRE.access.may_access_project` with ``params.check_project_owner``: their own 650 projects and those of root or the sre user, every project when the check is off). 651 Privileged callers are never asked: the CLI calls this in user mode only, the GUI always 652 (it goes through sre-wrapper).""" 653 return access.may_access_project(params.get_username_from_running_lab_name(running_lab_name), 654 username or '', check_owner=params.check_project_owner, 655 shared_owners=('root', params.sre_user))
Whether username, in user mode, may see and act on the project running_lab_name
(SRE.access.may_access_project() with params.check_project_owner: their own
projects and those of root or the sre user, every project when the check is off).
Privileged callers are never asked: the CLI calls this in user mode only, the GUI always
(it goes through sre-wrapper).