mcqpy-shiny 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mcqpy_shiny-0.2.0/PKG-INFO +17 -0
- mcqpy_shiny-0.2.0/README.md +6 -0
- mcqpy_shiny-0.2.0/pyproject.toml +32 -0
- mcqpy_shiny-0.2.0/src/mcqpy_shiny/__init__.py +22 -0
- mcqpy_shiny-0.2.0/src/mcqpy_shiny/app.py +49 -0
- mcqpy_shiny-0.2.0/src/mcqpy_shiny/app_types.py +9 -0
- mcqpy_shiny-0.2.0/src/mcqpy_shiny/embed_app.py +76 -0
- mcqpy_shiny-0.2.0/src/mcqpy_shiny/loader.py +98 -0
- mcqpy_shiny-0.2.0/src/mcqpy_shiny/quiz_app.py +254 -0
- mcqpy_shiny-0.2.0/src/mcqpy_shiny/styles.py +174 -0
- mcqpy_shiny-0.2.0/src/mcqpy_shiny/views.py +273 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: mcqpy-shiny
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Static-friendly Shiny quiz app for mcqpy bundles
|
|
5
|
+
Author: Mads-Peter
|
|
6
|
+
Author-email: Mads-Peter <machri@phys.au.dk>
|
|
7
|
+
Requires-Dist: mcqpy-core>=0.1.1
|
|
8
|
+
Requires-Dist: shiny>=1.2.1
|
|
9
|
+
Requires-Python: >=3.12, <3.14
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
# mcqpy-shiny
|
|
13
|
+
|
|
14
|
+
Minimal Py-Shiny quiz runner for browser-ready bundles exported by `mcqpy`.
|
|
15
|
+
|
|
16
|
+
This package is built from its package-local source tree under `packages/mcqpy-shiny/src/mcqpy_shiny`.
|
|
17
|
+
The Quarto/Shinylive example project now lives under `shiny-pages/` at the monorepo root.
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# mcqpy-shiny
|
|
2
|
+
|
|
3
|
+
Minimal Py-Shiny quiz runner for browser-ready bundles exported by `mcqpy`.
|
|
4
|
+
|
|
5
|
+
This package is built from its package-local source tree under `packages/mcqpy-shiny/src/mcqpy_shiny`.
|
|
6
|
+
The Quarto/Shinylive example project now lives under `shiny-pages/` at the monorepo root.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "mcqpy-shiny"
|
|
3
|
+
version = "0.2.0"
|
|
4
|
+
description = "Static-friendly Shiny quiz app for mcqpy bundles"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "Mads-Peter", email = "machri@phys.au.dk" }
|
|
8
|
+
]
|
|
9
|
+
requires-python = ">=3.12,<3.14"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"mcqpy-core>=0.1.1",
|
|
12
|
+
"shiny>=1.2.1",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
[tool.uv.sources]
|
|
16
|
+
mcqpy-core = { workspace = true }
|
|
17
|
+
|
|
18
|
+
[project.scripts]
|
|
19
|
+
mcqpy-shiny = "mcqpy_shiny.app:run_app"
|
|
20
|
+
|
|
21
|
+
[dependency-groups]
|
|
22
|
+
dev = [
|
|
23
|
+
"pytest>=8.4.1",
|
|
24
|
+
"shinylive>=0.8.5",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[build-system]
|
|
28
|
+
requires = ["uv_build>=0.8.18,<0.9.0"]
|
|
29
|
+
build-backend = "uv_build"
|
|
30
|
+
|
|
31
|
+
[tool.uv.build-backend]
|
|
32
|
+
module-root = "src"
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Py-Shiny app factory for mcqpy web bundles."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def create_app(*args: Any, **kwargs: Any):
|
|
9
|
+
from .app import create_app as _create_app
|
|
10
|
+
|
|
11
|
+
return _create_app(*args, **kwargs)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def __getattr__(name: str):
|
|
15
|
+
if name == "app":
|
|
16
|
+
from .app import app as _app
|
|
17
|
+
|
|
18
|
+
return _app
|
|
19
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
__all__ = ["app", "create_app"]
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Py-Shiny app for taking mcqpy web quizzes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from shiny import App
|
|
6
|
+
|
|
7
|
+
from mcqpy_core.web import WebQuizBundle, decode_quiz_token, grade_web_quiz
|
|
8
|
+
from .loader import load_bundle
|
|
9
|
+
from .quiz_app import create_quiz_app
|
|
10
|
+
|
|
11
|
+
async def _load_bundle(source: str) -> dict:
|
|
12
|
+
return await load_bundle(source)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _grade_bundle(bundle: dict, answers: dict) -> dict:
|
|
16
|
+
validated_bundle = WebQuizBundle.model_validate(bundle)
|
|
17
|
+
return grade_web_quiz(validated_bundle, answers).model_dump()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def create_app(
|
|
21
|
+
*,
|
|
22
|
+
fixed_url: str | None = None,
|
|
23
|
+
fixed_token: str | None = None,
|
|
24
|
+
allow_manual_load: bool = True,
|
|
25
|
+
title: str = "MCQPy Quiz",
|
|
26
|
+
card_width: str = "900px",
|
|
27
|
+
) -> App:
|
|
28
|
+
return create_quiz_app(
|
|
29
|
+
load_bundle=_load_bundle,
|
|
30
|
+
decode_token=decode_quiz_token,
|
|
31
|
+
grade_bundle=_grade_bundle,
|
|
32
|
+
missing_bundle_message="Load a quiz bundle to begin. Bundles are exported with `mcqpy export web`.",
|
|
33
|
+
fixed_url=fixed_url,
|
|
34
|
+
fixed_token=fixed_token,
|
|
35
|
+
allow_manual_load=allow_manual_load,
|
|
36
|
+
title=title,
|
|
37
|
+
card_width=card_width,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
app = create_app()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def run_app() -> None:
|
|
45
|
+
"""Entry point for `mcqpy-shiny`."""
|
|
46
|
+
|
|
47
|
+
from shiny import run_app
|
|
48
|
+
|
|
49
|
+
run_app(app)
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Shared callable types for quiz app construction."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Awaitable, Callable
|
|
6
|
+
|
|
7
|
+
BundleLoader = Callable[[str], Awaitable[dict]]
|
|
8
|
+
TokenDecoder = Callable[[str], str]
|
|
9
|
+
BundleGrader = Callable[[dict, dict], dict]
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Browser-safe Py-Shiny app for Shinylive embeds."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from urllib.parse import urljoin
|
|
6
|
+
|
|
7
|
+
from shiny import App
|
|
8
|
+
from mcqpy_core.web import WebQuizBundle, decode_quiz_token, grade_web_quiz
|
|
9
|
+
|
|
10
|
+
from .quiz_app import create_quiz_app
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def load_bundle_json(raw: str) -> dict:
|
|
14
|
+
return WebQuizBundle.model_validate_json(raw).model_dump()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _resolve_question_images(bundle: dict, source: str) -> dict:
|
|
18
|
+
updated_questions = []
|
|
19
|
+
for question in bundle.get("questions", []):
|
|
20
|
+
updated = dict(question)
|
|
21
|
+
updated["images"] = [
|
|
22
|
+
image if image.startswith(("http://", "https://", "data:")) else urljoin(source, image)
|
|
23
|
+
for image in question.get("images", [])
|
|
24
|
+
]
|
|
25
|
+
updated_questions.append(updated)
|
|
26
|
+
|
|
27
|
+
updated_bundle = dict(bundle)
|
|
28
|
+
updated_bundle["questions"] = updated_questions
|
|
29
|
+
return updated_bundle
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
async def _fetch_text(url: str) -> str:
|
|
33
|
+
try:
|
|
34
|
+
from pyodide.http import pyfetch # type: ignore
|
|
35
|
+
except ImportError: # pragma: no cover
|
|
36
|
+
from urllib.request import urlopen
|
|
37
|
+
|
|
38
|
+
with urlopen(url) as response: # noqa: S310
|
|
39
|
+
return response.read().decode("utf-8")
|
|
40
|
+
|
|
41
|
+
response = await pyfetch(url)
|
|
42
|
+
if not response.ok:
|
|
43
|
+
raise ValueError(f"Failed to load quiz bundle from {url}")
|
|
44
|
+
return await response.string()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
async def load_bundle(source: str) -> dict:
|
|
48
|
+
raw = await _fetch_text(source)
|
|
49
|
+
bundle = load_bundle_json(raw)
|
|
50
|
+
return _resolve_question_images(bundle, source)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _grade_bundle(bundle: dict, answers: dict) -> dict:
|
|
54
|
+
validated_bundle = WebQuizBundle.model_validate(bundle)
|
|
55
|
+
return grade_web_quiz(validated_bundle, answers).model_dump()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def create_app(
|
|
59
|
+
*,
|
|
60
|
+
fixed_url: str | None = None,
|
|
61
|
+
fixed_token: str | None = None,
|
|
62
|
+
allow_manual_load: bool = True,
|
|
63
|
+
title: str = "MCQPy Quiz",
|
|
64
|
+
card_width: str = "900px",
|
|
65
|
+
) -> App:
|
|
66
|
+
return create_quiz_app(
|
|
67
|
+
load_bundle=load_bundle,
|
|
68
|
+
decode_token=decode_quiz_token,
|
|
69
|
+
grade_bundle=_grade_bundle,
|
|
70
|
+
missing_bundle_message="Load a quiz bundle to begin.",
|
|
71
|
+
fixed_url=fixed_url,
|
|
72
|
+
fixed_token=fixed_token,
|
|
73
|
+
allow_manual_load=allow_manual_load,
|
|
74
|
+
title=title,
|
|
75
|
+
card_width=card_width,
|
|
76
|
+
)
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Bundle loading helpers for local and static-hosted quizzes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import mimetypes
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from urllib.parse import urljoin
|
|
9
|
+
from urllib.request import urlopen
|
|
10
|
+
|
|
11
|
+
from mcqpy_core.web import WebQuizBundle
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def load_bundle_file(path: str | Path) -> dict:
|
|
15
|
+
return WebQuizBundle.load_from_file(path).model_dump()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def load_bundle_json(raw: str) -> dict:
|
|
19
|
+
return WebQuizBundle.model_validate_json(raw).model_dump()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _is_remote_url(value: str) -> bool:
|
|
23
|
+
return value.startswith("http://") or value.startswith("https://")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _path_to_data_url(path: Path) -> str:
|
|
27
|
+
mime_type, _ = mimetypes.guess_type(path.name)
|
|
28
|
+
if mime_type is None:
|
|
29
|
+
mime_type = "application/octet-stream"
|
|
30
|
+
encoded = base64.b64encode(path.read_bytes()).decode("ascii")
|
|
31
|
+
return f"data:{mime_type};base64,{encoded}"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _resolve_question_images(bundle: dict, source: str) -> dict:
|
|
35
|
+
updated_questions = []
|
|
36
|
+
for question in bundle["questions"]:
|
|
37
|
+
updated = dict(question)
|
|
38
|
+
updated["images"] = [
|
|
39
|
+
image if _is_remote_url(image) else urljoin(source, image)
|
|
40
|
+
for image in question.get("images", [])
|
|
41
|
+
]
|
|
42
|
+
updated_questions.append(updated)
|
|
43
|
+
updated_bundle = dict(bundle)
|
|
44
|
+
updated_bundle["questions"] = updated_questions
|
|
45
|
+
return updated_bundle
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _resolve_local_question_images(bundle: dict, bundle_dir: Path) -> dict:
|
|
49
|
+
updated_questions = []
|
|
50
|
+
for question in bundle["questions"]:
|
|
51
|
+
images = []
|
|
52
|
+
for image in question.get("images", []):
|
|
53
|
+
image_path = Path(image)
|
|
54
|
+
if image_path.is_absolute():
|
|
55
|
+
resolved = image_path
|
|
56
|
+
else:
|
|
57
|
+
resolved = (bundle_dir / image_path).resolve()
|
|
58
|
+
|
|
59
|
+
images.append(_path_to_data_url(resolved))
|
|
60
|
+
|
|
61
|
+
updated = dict(question)
|
|
62
|
+
updated["images"] = images
|
|
63
|
+
updated_questions.append(updated)
|
|
64
|
+
|
|
65
|
+
updated_bundle = dict(bundle)
|
|
66
|
+
updated_bundle["questions"] = updated_questions
|
|
67
|
+
return updated_bundle
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
async def _fetch_text(url: str) -> str:
|
|
71
|
+
try:
|
|
72
|
+
from pyodide.http import pyfetch # type: ignore
|
|
73
|
+
except ImportError:
|
|
74
|
+
with urlopen(url) as response: # noqa: S310
|
|
75
|
+
return response.read().decode("utf-8")
|
|
76
|
+
|
|
77
|
+
response = await pyfetch(url)
|
|
78
|
+
if not response.ok:
|
|
79
|
+
raise ValueError(f"Failed to load quiz bundle from {url}")
|
|
80
|
+
return await response.string()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def load_bundle_from_path(path: str | Path) -> dict:
|
|
84
|
+
bundle_path = Path(path).resolve()
|
|
85
|
+
bundle = load_bundle_file(bundle_path)
|
|
86
|
+
return _resolve_local_question_images(bundle, bundle_path.parent)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
async def load_bundle_from_url(url: str) -> dict:
|
|
90
|
+
raw = await _fetch_text(url)
|
|
91
|
+
bundle = load_bundle_json(raw)
|
|
92
|
+
return _resolve_question_images(bundle, url)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
async def load_bundle(source: str) -> dict:
|
|
96
|
+
if _is_remote_url(source):
|
|
97
|
+
return await load_bundle_from_url(source)
|
|
98
|
+
return load_bundle_from_path(source)
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"""Shared quiz app factory used by local and Shinylive runtimes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import traceback
|
|
6
|
+
|
|
7
|
+
from shiny import App, reactive, render, ui
|
|
8
|
+
|
|
9
|
+
from .app_types import BundleGrader, BundleLoader, TokenDecoder
|
|
10
|
+
from .styles import MATHJAX_BOOTSTRAP, MATHJAX_HEAD, build_css
|
|
11
|
+
from .views import (
|
|
12
|
+
answer_input_id,
|
|
13
|
+
render_error_card,
|
|
14
|
+
render_load_panel,
|
|
15
|
+
render_question_card,
|
|
16
|
+
render_results_card,
|
|
17
|
+
render_review_card,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def create_quiz_app(
|
|
22
|
+
*,
|
|
23
|
+
load_bundle: BundleLoader,
|
|
24
|
+
decode_token: TokenDecoder,
|
|
25
|
+
grade_bundle: BundleGrader,
|
|
26
|
+
missing_bundle_message: str,
|
|
27
|
+
fixed_url: str | None = None,
|
|
28
|
+
fixed_token: str | None = None,
|
|
29
|
+
allow_manual_load: bool = True,
|
|
30
|
+
title: str = "MCQPy Quiz",
|
|
31
|
+
card_width: str = "900px",
|
|
32
|
+
) -> App:
|
|
33
|
+
app_ui = ui.page_fillable(
|
|
34
|
+
ui.head_content(
|
|
35
|
+
ui.tags.script(MATHJAX_HEAD),
|
|
36
|
+
ui.tags.script(src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-svg.js"),
|
|
37
|
+
ui.tags.style(build_css(card_width)),
|
|
38
|
+
ui.tags.script(MATHJAX_BOOTSTRAP),
|
|
39
|
+
),
|
|
40
|
+
ui.div(
|
|
41
|
+
ui.h1(title),
|
|
42
|
+
ui.output_ui("load_panel"),
|
|
43
|
+
ui.hr(),
|
|
44
|
+
ui.output_ui("content"),
|
|
45
|
+
class_="mcqpy-shell",
|
|
46
|
+
),
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
def server(input, output, session):
|
|
50
|
+
bundle = reactive.value(None)
|
|
51
|
+
answers = reactive.value({})
|
|
52
|
+
current_index = reactive.value(0)
|
|
53
|
+
load_error = reactive.value(None)
|
|
54
|
+
result = reactive.value(None)
|
|
55
|
+
result_error = reactive.value(None)
|
|
56
|
+
auto_loaded = reactive.value(False)
|
|
57
|
+
|
|
58
|
+
def _bundle() -> dict | None:
|
|
59
|
+
return bundle.get()
|
|
60
|
+
|
|
61
|
+
def _current_questions() -> list[dict]:
|
|
62
|
+
loaded = _bundle()
|
|
63
|
+
return [] if loaded is None else loaded["questions"]
|
|
64
|
+
|
|
65
|
+
def _store_current_answer() -> None:
|
|
66
|
+
loaded = _bundle()
|
|
67
|
+
if loaded is None:
|
|
68
|
+
return
|
|
69
|
+
|
|
70
|
+
index = current_index.get()
|
|
71
|
+
questions = loaded["questions"]
|
|
72
|
+
if not (0 <= index < len(questions)):
|
|
73
|
+
return
|
|
74
|
+
|
|
75
|
+
input_id = answer_input_id(index)
|
|
76
|
+
current_value = input[input_id]()
|
|
77
|
+
next_answers = dict(answers.get())
|
|
78
|
+
next_answers[questions[index]["qid"]] = current_value
|
|
79
|
+
answers.set(next_answers)
|
|
80
|
+
|
|
81
|
+
async def _load_source(source: str) -> None:
|
|
82
|
+
if not source:
|
|
83
|
+
load_error.set("Provide a quiz bundle URL, token, or local path.")
|
|
84
|
+
return
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
loaded = await load_bundle(source)
|
|
88
|
+
except Exception as exc:
|
|
89
|
+
load_error.set(str(exc))
|
|
90
|
+
bundle.set(None)
|
|
91
|
+
result.set(None)
|
|
92
|
+
result_error.set(None)
|
|
93
|
+
return
|
|
94
|
+
|
|
95
|
+
load_error.set(None)
|
|
96
|
+
bundle.set(loaded)
|
|
97
|
+
answers.set({})
|
|
98
|
+
result.set(None)
|
|
99
|
+
result_error.set(None)
|
|
100
|
+
current_index.set(0)
|
|
101
|
+
|
|
102
|
+
@reactive.effect
|
|
103
|
+
async def _auto_load_fixed_bundle():
|
|
104
|
+
if auto_loaded.get():
|
|
105
|
+
return
|
|
106
|
+
|
|
107
|
+
source = None
|
|
108
|
+
if fixed_token:
|
|
109
|
+
source = decode_token(fixed_token)
|
|
110
|
+
elif fixed_url:
|
|
111
|
+
source = fixed_url
|
|
112
|
+
|
|
113
|
+
if source is None:
|
|
114
|
+
return
|
|
115
|
+
|
|
116
|
+
auto_loaded.set(True)
|
|
117
|
+
await _load_source(source)
|
|
118
|
+
|
|
119
|
+
@reactive.effect
|
|
120
|
+
@reactive.event(input.load_link)
|
|
121
|
+
async def _load_link():
|
|
122
|
+
await _load_source(input.quiz_url().strip())
|
|
123
|
+
|
|
124
|
+
@reactive.effect
|
|
125
|
+
@reactive.event(input.load_token)
|
|
126
|
+
async def _load_token():
|
|
127
|
+
token = input.quiz_token().strip()
|
|
128
|
+
try:
|
|
129
|
+
source = decode_token(token)
|
|
130
|
+
except ValueError as exc:
|
|
131
|
+
load_error.set(str(exc))
|
|
132
|
+
return
|
|
133
|
+
await _load_source(source)
|
|
134
|
+
|
|
135
|
+
@reactive.effect
|
|
136
|
+
@reactive.event(input.next_question)
|
|
137
|
+
def _next_question():
|
|
138
|
+
_store_current_answer()
|
|
139
|
+
questions = _current_questions()
|
|
140
|
+
if questions:
|
|
141
|
+
current_index.set(min(len(questions), current_index.get() + 1))
|
|
142
|
+
|
|
143
|
+
@reactive.effect
|
|
144
|
+
@reactive.event(input.prev_question)
|
|
145
|
+
def _prev_question():
|
|
146
|
+
_store_current_answer()
|
|
147
|
+
current_index.set(max(0, current_index.get() - 1))
|
|
148
|
+
|
|
149
|
+
@reactive.effect
|
|
150
|
+
@reactive.event(input.jump_slider)
|
|
151
|
+
def _jump_from_slider():
|
|
152
|
+
loaded = _bundle()
|
|
153
|
+
if loaded is None:
|
|
154
|
+
return
|
|
155
|
+
_store_current_answer()
|
|
156
|
+
target = input.jump_slider()
|
|
157
|
+
if target is None:
|
|
158
|
+
return
|
|
159
|
+
current_index.set(min(max(target - 1, 0), len(loaded["questions"])))
|
|
160
|
+
|
|
161
|
+
@reactive.effect
|
|
162
|
+
@reactive.event(input.jump_number)
|
|
163
|
+
def _jump_from_number():
|
|
164
|
+
loaded = _bundle()
|
|
165
|
+
if loaded is None:
|
|
166
|
+
return
|
|
167
|
+
_store_current_answer()
|
|
168
|
+
target = input.jump_number()
|
|
169
|
+
if target is None:
|
|
170
|
+
return
|
|
171
|
+
current_index.set(min(max(target - 1, 0), len(loaded["questions"])))
|
|
172
|
+
|
|
173
|
+
@reactive.effect
|
|
174
|
+
@reactive.event(input.jump_grid)
|
|
175
|
+
def _jump_from_grid():
|
|
176
|
+
loaded = _bundle()
|
|
177
|
+
if loaded is None:
|
|
178
|
+
return
|
|
179
|
+
_store_current_answer()
|
|
180
|
+
target = input.jump_grid()
|
|
181
|
+
if target is None:
|
|
182
|
+
return
|
|
183
|
+
current_index.set(min(max(int(target) - 1, 0), len(loaded["questions"])))
|
|
184
|
+
|
|
185
|
+
@reactive.effect
|
|
186
|
+
@reactive.event(input.submit_quiz)
|
|
187
|
+
def _submit_quiz():
|
|
188
|
+
loaded = _bundle()
|
|
189
|
+
if loaded is None:
|
|
190
|
+
return
|
|
191
|
+
_store_current_answer()
|
|
192
|
+
try:
|
|
193
|
+
graded = grade_bundle(loaded, answers.get())
|
|
194
|
+
except Exception:
|
|
195
|
+
result.set(None)
|
|
196
|
+
result_error.set(traceback.format_exc())
|
|
197
|
+
return
|
|
198
|
+
|
|
199
|
+
result_error.set(None)
|
|
200
|
+
result.set(graded)
|
|
201
|
+
|
|
202
|
+
@reactive.effect
|
|
203
|
+
@reactive.event(input.restart_quiz)
|
|
204
|
+
def _restart_quiz():
|
|
205
|
+
answers.set({})
|
|
206
|
+
result.set(None)
|
|
207
|
+
result_error.set(None)
|
|
208
|
+
current_index.set(0)
|
|
209
|
+
|
|
210
|
+
@output
|
|
211
|
+
@render.ui
|
|
212
|
+
def load_panel():
|
|
213
|
+
return render_load_panel(
|
|
214
|
+
bundle_loaded=_bundle() is not None,
|
|
215
|
+
fixed_url=fixed_url,
|
|
216
|
+
fixed_token=fixed_token,
|
|
217
|
+
allow_manual_load=allow_manual_load,
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
@output
|
|
221
|
+
@render.ui
|
|
222
|
+
def content():
|
|
223
|
+
if load_error.get():
|
|
224
|
+
return ui.p(load_error.get(), class_="text-danger")
|
|
225
|
+
|
|
226
|
+
if result_error.get():
|
|
227
|
+
return render_error_card(result_error.get())
|
|
228
|
+
|
|
229
|
+
loaded = _bundle()
|
|
230
|
+
if loaded is None:
|
|
231
|
+
return ui.markdown(missing_bundle_message)
|
|
232
|
+
|
|
233
|
+
if result.get() is not None:
|
|
234
|
+
graded = result.get()
|
|
235
|
+
try:
|
|
236
|
+
return render_results_card(loaded, graded)
|
|
237
|
+
except Exception:
|
|
238
|
+
result_error.set(traceback.format_exc())
|
|
239
|
+
return render_error_card(result_error.get())
|
|
240
|
+
|
|
241
|
+
questions = loaded["questions"]
|
|
242
|
+
index = current_index.get()
|
|
243
|
+
|
|
244
|
+
if index >= len(questions):
|
|
245
|
+
return render_review_card(questions, answers.get(), index)
|
|
246
|
+
|
|
247
|
+
return render_question_card(
|
|
248
|
+
loaded=loaded,
|
|
249
|
+
answers=answers.get(),
|
|
250
|
+
index=index,
|
|
251
|
+
title=title,
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
return App(app_ui, server)
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Styling and MathJax bootstrap for the quiz app."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def build_css(card_width: str) -> str:
|
|
7
|
+
return """
|
|
8
|
+
.mcqpy-shell {
|
|
9
|
+
max-width: 1200px;
|
|
10
|
+
margin: 0 auto;
|
|
11
|
+
padding: 1.5rem 1rem 3rem;
|
|
12
|
+
}
|
|
13
|
+
.mcqpy-card {
|
|
14
|
+
width: __CARD_WIDTH__;
|
|
15
|
+
max-width: __CARD_WIDTH__;
|
|
16
|
+
min-width: __CARD_WIDTH__;
|
|
17
|
+
margin: 0 auto;
|
|
18
|
+
box-sizing: border-box;
|
|
19
|
+
overflow-x: hidden;
|
|
20
|
+
}
|
|
21
|
+
.mcqpy-card .card-body {
|
|
22
|
+
overflow-x: hidden;
|
|
23
|
+
}
|
|
24
|
+
.mcqpy-question-text,
|
|
25
|
+
.mcqpy-question-text p,
|
|
26
|
+
.mcqpy-question-text li {
|
|
27
|
+
font-size: 1.05rem;
|
|
28
|
+
line-height: 1.65;
|
|
29
|
+
overflow-wrap: anywhere;
|
|
30
|
+
word-break: break-word;
|
|
31
|
+
}
|
|
32
|
+
.mcqpy-question-meta {
|
|
33
|
+
margin: 0.25rem 0 1rem;
|
|
34
|
+
color: #475569;
|
|
35
|
+
font-size: 0.98rem;
|
|
36
|
+
}
|
|
37
|
+
.mcqpy-media {
|
|
38
|
+
margin: 1rem 0 1.5rem;
|
|
39
|
+
min-height: 0;
|
|
40
|
+
}
|
|
41
|
+
.mcqpy-media img {
|
|
42
|
+
display: block;
|
|
43
|
+
max-width: min(100%, 900px);
|
|
44
|
+
height: auto;
|
|
45
|
+
margin: 0 auto;
|
|
46
|
+
}
|
|
47
|
+
.mcqpy-media pre {
|
|
48
|
+
max-width: 100%;
|
|
49
|
+
overflow-x: auto;
|
|
50
|
+
white-space: pre-wrap;
|
|
51
|
+
}
|
|
52
|
+
.mcqpy-media code,
|
|
53
|
+
.mcqpy-question-text code {
|
|
54
|
+
overflow-wrap: anywhere;
|
|
55
|
+
word-break: break-word;
|
|
56
|
+
}
|
|
57
|
+
.mcqpy-answer-group .shiny-input-radiogroup,
|
|
58
|
+
.mcqpy-answer-group .shiny-input-checkboxgroup {
|
|
59
|
+
width: 100%;
|
|
60
|
+
}
|
|
61
|
+
.mcqpy-answer-group .radio,
|
|
62
|
+
.mcqpy-answer-group .checkbox {
|
|
63
|
+
display: block;
|
|
64
|
+
width: 100%;
|
|
65
|
+
margin-bottom: 0.9rem;
|
|
66
|
+
}
|
|
67
|
+
.mcqpy-answer-group label.radio,
|
|
68
|
+
.mcqpy-answer-group label.checkbox {
|
|
69
|
+
display: block;
|
|
70
|
+
width: 100%;
|
|
71
|
+
padding: 0.9rem 1rem;
|
|
72
|
+
border: 1px solid #d9dee5;
|
|
73
|
+
border-radius: 0.75rem;
|
|
74
|
+
background: #fff;
|
|
75
|
+
}
|
|
76
|
+
.mcqpy-answer-group input[type="radio"],
|
|
77
|
+
.mcqpy-answer-group input[type="checkbox"] {
|
|
78
|
+
margin-right: 0.65rem;
|
|
79
|
+
}
|
|
80
|
+
.mcqpy-nav-row {
|
|
81
|
+
margin-top: 1.25rem;
|
|
82
|
+
}
|
|
83
|
+
.mcqpy-jump-row {
|
|
84
|
+
margin: 0.5rem 0 1rem;
|
|
85
|
+
align-items: end;
|
|
86
|
+
}
|
|
87
|
+
.mcqpy-results-chart {
|
|
88
|
+
margin: 1.25rem 0 0.75rem;
|
|
89
|
+
padding: 0.75rem;
|
|
90
|
+
border: 1px solid #e2e8f0;
|
|
91
|
+
border-radius: 0.85rem;
|
|
92
|
+
background: #fcfdff;
|
|
93
|
+
}
|
|
94
|
+
.mcqpy-results-chart svg {
|
|
95
|
+
display: block;
|
|
96
|
+
width: 100%;
|
|
97
|
+
height: auto;
|
|
98
|
+
}
|
|
99
|
+
.mcqpy-overview-grid {
|
|
100
|
+
display: grid;
|
|
101
|
+
grid-template-columns: repeat(auto-fit, minmax(68px, 1fr));
|
|
102
|
+
gap: 0.6rem;
|
|
103
|
+
margin: 0.9rem 0 1.25rem;
|
|
104
|
+
}
|
|
105
|
+
.mcqpy-overview-button {
|
|
106
|
+
display: flex;
|
|
107
|
+
align-items: center;
|
|
108
|
+
justify-content: center;
|
|
109
|
+
width: 100%;
|
|
110
|
+
min-height: 58px;
|
|
111
|
+
padding: 0.7rem;
|
|
112
|
+
border: 1px solid #d9dee5;
|
|
113
|
+
border-radius: 0.75rem;
|
|
114
|
+
background: #fff;
|
|
115
|
+
text-align: center;
|
|
116
|
+
}
|
|
117
|
+
.mcqpy-overview-button.is-answered {
|
|
118
|
+
background: #eff6ff;
|
|
119
|
+
border-color: #93c5fd;
|
|
120
|
+
}
|
|
121
|
+
.mcqpy-overview-button.is-current {
|
|
122
|
+
border-color: #2563eb;
|
|
123
|
+
box-shadow: inset 0 0 0 1px #2563eb;
|
|
124
|
+
}
|
|
125
|
+
.mcqpy-overview-number {
|
|
126
|
+
font-size: 1rem;
|
|
127
|
+
font-weight: 700;
|
|
128
|
+
color: #2563eb;
|
|
129
|
+
}
|
|
130
|
+
@media (max-width: 820px) {
|
|
131
|
+
.mcqpy-card {
|
|
132
|
+
width: 100%;
|
|
133
|
+
max-width: 100%;
|
|
134
|
+
min-width: 0;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
""".replace("__CARD_WIDTH__", card_width)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
MATHJAX_HEAD = """
|
|
141
|
+
window.MathJax = {
|
|
142
|
+
tex: { inlineMath: [['$', '$'], ['\\\\(', '\\\\)']], displayMath: [['$$', '$$'], ['\\\\[', '\\\\]']] },
|
|
143
|
+
svg: { fontCache: 'global' }
|
|
144
|
+
};
|
|
145
|
+
"""
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
MATHJAX_BOOTSTRAP = """
|
|
149
|
+
(() => {
|
|
150
|
+
let timer = null;
|
|
151
|
+
let observer = null;
|
|
152
|
+
const typeset = () => {
|
|
153
|
+
if (!window.MathJax || !window.MathJax.typesetPromise) return;
|
|
154
|
+
const nodes = document.querySelectorAll('.mcqpy-math');
|
|
155
|
+
if (nodes.length) window.MathJax.typesetPromise(Array.from(nodes)).catch(() => {});
|
|
156
|
+
};
|
|
157
|
+
const schedule = () => {
|
|
158
|
+
clearTimeout(timer);
|
|
159
|
+
timer = setTimeout(typeset, 125);
|
|
160
|
+
};
|
|
161
|
+
const install = () => {
|
|
162
|
+
schedule();
|
|
163
|
+
if (observer || !document.body) return;
|
|
164
|
+
observer = new MutationObserver(schedule);
|
|
165
|
+
observer.observe(document.body, { childList: true, subtree: true });
|
|
166
|
+
};
|
|
167
|
+
if (document.readyState === 'loading') {
|
|
168
|
+
document.addEventListener('DOMContentLoaded', install, { once: true });
|
|
169
|
+
} else {
|
|
170
|
+
install();
|
|
171
|
+
}
|
|
172
|
+
window.addEventListener('load', schedule);
|
|
173
|
+
})();
|
|
174
|
+
"""
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
"""UI helpers for quiz rendering."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from html import escape
|
|
6
|
+
|
|
7
|
+
from shiny import ui
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def answer_input_id(index: int) -> str:
|
|
11
|
+
return f"answer_{index}"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _choice_map(choices: list[str]) -> dict[str, str]:
|
|
15
|
+
return {
|
|
16
|
+
chr(65 + index): f"({chr(65 + index)}) {choice}"
|
|
17
|
+
for index, choice in enumerate(choices)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def render_question_media(question: dict) -> list:
|
|
22
|
+
media = []
|
|
23
|
+
captions = question.get("image_captions", {})
|
|
24
|
+
|
|
25
|
+
for index, image in enumerate(question.get("images", [])):
|
|
26
|
+
media.append(ui.img(src=image, style="max-width: 100%; height: auto;"))
|
|
27
|
+
caption = captions.get(str(index), captions.get(index))
|
|
28
|
+
if caption:
|
|
29
|
+
media.append(ui.p(caption, class_="text-muted"))
|
|
30
|
+
|
|
31
|
+
shared_caption = captions.get("-1", captions.get(-1))
|
|
32
|
+
if shared_caption:
|
|
33
|
+
media.append(ui.p(shared_caption, class_="text-muted"))
|
|
34
|
+
|
|
35
|
+
for block in question.get("code_blocks", []):
|
|
36
|
+
media.append(
|
|
37
|
+
ui.tags.pre(
|
|
38
|
+
ui.tags.code(
|
|
39
|
+
block.get("code", ""),
|
|
40
|
+
**{"data-language": block.get("language") or "text"},
|
|
41
|
+
)
|
|
42
|
+
)
|
|
43
|
+
)
|
|
44
|
+
return media
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def question_answer_ui(question: dict, index: int):
|
|
48
|
+
input_id = answer_input_id(index)
|
|
49
|
+
choices = _choice_map(question["choices"])
|
|
50
|
+
if question["question_type"] == "single":
|
|
51
|
+
return ui.input_radio_buttons(input_id, "Select your answer", choices=choices)
|
|
52
|
+
return ui.input_checkbox_group(input_id, "Select your answers", choices=choices)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def result_chart_svg(questions: list[dict], graded: dict) -> ui.HTML:
|
|
56
|
+
width = 760
|
|
57
|
+
row_height = 30
|
|
58
|
+
top = 28
|
|
59
|
+
left = 92
|
|
60
|
+
right = 58
|
|
61
|
+
bottom = 36
|
|
62
|
+
plot_width = width - left - right
|
|
63
|
+
height = top + bottom + row_height * max(len(graded["question_results"]), 1)
|
|
64
|
+
max_points = max((item["max_points"] for item in graded["question_results"]), default=1)
|
|
65
|
+
|
|
66
|
+
parts = [
|
|
67
|
+
f'<svg viewBox="0 0 {width} {height}" width="100%" height="auto" role="img" aria-label="Points by question">',
|
|
68
|
+
f'<rect x="0" y="0" width="{width}" height="{height}" fill="#ffffff"/>',
|
|
69
|
+
f'<line x1="{left}" y1="{top - 8}" x2="{left}" y2="{height - bottom + 4}" stroke="#c9d2dc" stroke-width="1"/>',
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
for idx, (question, item) in enumerate(zip(questions, graded["question_results"], strict=False)):
|
|
73
|
+
y = top + idx * row_height
|
|
74
|
+
bar_width = 0 if max_points == 0 else (item["points"] / max_points) * plot_width
|
|
75
|
+
max_width = 0 if max_points == 0 else (item["max_points"] / max_points) * plot_width
|
|
76
|
+
label = escape(str(idx + 1))
|
|
77
|
+
title = escape(question["slug"])
|
|
78
|
+
|
|
79
|
+
parts.extend(
|
|
80
|
+
[
|
|
81
|
+
f'<title>{title}</title>',
|
|
82
|
+
f'<text x="{left - 10}" y="{y + 17}" text-anchor="end" font-size="13" fill="#334155">{label}</text>',
|
|
83
|
+
f'<rect x="{left}" y="{y + 4}" rx="6" ry="6" width="{max_width}" height="18" fill="#e9eef4"/>',
|
|
84
|
+
f'<rect x="{left}" y="{y + 4}" rx="6" ry="6" width="{bar_width}" height="18" fill="#3b82f6"/>',
|
|
85
|
+
f'<text x="{left + max_width + 8}" y="{y + 18}" font-size="12" fill="#475569">{item["points"]}/{item["max_points"]}</text>',
|
|
86
|
+
]
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
for tick in range(max_points + 1):
|
|
90
|
+
x = left + (0 if max_points == 0 else (tick / max_points) * plot_width)
|
|
91
|
+
parts.extend(
|
|
92
|
+
[
|
|
93
|
+
f'<line x1="{x}" y1="{top - 8}" x2="{x}" y2="{height - bottom + 4}" stroke="#f1f5f9" stroke-width="1"/>',
|
|
94
|
+
f'<text x="{x}" y="{height - 10}" text-anchor="middle" font-size="12" fill="#64748b">{tick}</text>',
|
|
95
|
+
]
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
parts.append("</svg>")
|
|
99
|
+
return ui.HTML("".join(parts))
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def question_overview_grid(questions: list[dict], answers: dict, current_index: int):
|
|
103
|
+
buttons = []
|
|
104
|
+
for idx, question in enumerate(questions):
|
|
105
|
+
saved = answers.get(question["qid"])
|
|
106
|
+
is_answered = saved not in (None, [], ())
|
|
107
|
+
classes = ["mcqpy-overview-button"]
|
|
108
|
+
if is_answered:
|
|
109
|
+
classes.append("is-answered")
|
|
110
|
+
if idx == current_index:
|
|
111
|
+
classes.append("is-current")
|
|
112
|
+
|
|
113
|
+
buttons.append(
|
|
114
|
+
ui.tags.button(
|
|
115
|
+
ui.tags.span(str(idx + 1), class_="mcqpy-overview-number"),
|
|
116
|
+
type="button",
|
|
117
|
+
onclick=f"Shiny.setInputValue('jump_grid', {idx + 1}, {{priority: 'event'}})",
|
|
118
|
+
class_=" ".join(classes),
|
|
119
|
+
title=question["slug"],
|
|
120
|
+
)
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
return ui.div(*buttons, class_="mcqpy-overview-grid")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def render_error_card(error_text: str):
|
|
127
|
+
return ui.card(
|
|
128
|
+
ui.h2("Grading Error"),
|
|
129
|
+
ui.tags.pre(error_text),
|
|
130
|
+
ui.input_action_button("restart_quiz", "Restart quiz"),
|
|
131
|
+
class_="mcqpy-card",
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def render_load_panel(
|
|
136
|
+
*,
|
|
137
|
+
bundle_loaded: bool,
|
|
138
|
+
fixed_url: str | None,
|
|
139
|
+
fixed_token: str | None,
|
|
140
|
+
allow_manual_load: bool,
|
|
141
|
+
):
|
|
142
|
+
if bundle_loaded:
|
|
143
|
+
return ui.div()
|
|
144
|
+
|
|
145
|
+
if fixed_url or fixed_token:
|
|
146
|
+
if not allow_manual_load:
|
|
147
|
+
return ui.div()
|
|
148
|
+
helper = "A fixed quiz is configured for this app, or you can load another one."
|
|
149
|
+
else:
|
|
150
|
+
helper = "Load a quiz bundle from a public link or an obfuscated token."
|
|
151
|
+
|
|
152
|
+
return ui.card(
|
|
153
|
+
ui.p(helper),
|
|
154
|
+
ui.row(
|
|
155
|
+
ui.column(
|
|
156
|
+
6,
|
|
157
|
+
ui.input_text("quiz_url", "Quiz bundle URL", placeholder="https://.../quiz.json"),
|
|
158
|
+
ui.input_action_button("load_link", "Load from link"),
|
|
159
|
+
),
|
|
160
|
+
ui.column(
|
|
161
|
+
6,
|
|
162
|
+
ui.input_text("quiz_token", "Obfuscated token", placeholder="mcqpy:..."),
|
|
163
|
+
ui.input_action_button("load_token", "Load from token"),
|
|
164
|
+
),
|
|
165
|
+
),
|
|
166
|
+
class_="mcqpy-card",
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def render_results_card(loaded: dict, graded: dict):
|
|
171
|
+
summary = [
|
|
172
|
+
ui.h2("Results"),
|
|
173
|
+
ui.p(f"Score: {graded['points']} / {graded['max_points']}"),
|
|
174
|
+
ui.div(
|
|
175
|
+
ui.h3("Points by question"),
|
|
176
|
+
result_chart_svg(loaded["questions"], graded),
|
|
177
|
+
ui.p(
|
|
178
|
+
"Bars show earned points for each question number; hover labels reflect the full slug.",
|
|
179
|
+
class_="text-muted",
|
|
180
|
+
),
|
|
181
|
+
class_="mcqpy-results-chart",
|
|
182
|
+
),
|
|
183
|
+
ui.tags.ul(
|
|
184
|
+
*[
|
|
185
|
+
ui.tags.li(f"{question['slug']}: {item['points']}/{item['max_points']}")
|
|
186
|
+
for question, item in zip(
|
|
187
|
+
loaded["questions"], graded["question_results"], strict=False
|
|
188
|
+
)
|
|
189
|
+
]
|
|
190
|
+
),
|
|
191
|
+
ui.input_action_button("restart_quiz", "Restart quiz"),
|
|
192
|
+
]
|
|
193
|
+
return ui.card(*summary, class_="mcqpy-card")
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def render_review_card(questions: list[dict], answers: dict, index: int):
|
|
197
|
+
items = []
|
|
198
|
+
for question in questions:
|
|
199
|
+
saved = answers.get(question["qid"])
|
|
200
|
+
items.append(
|
|
201
|
+
ui.tags.li(
|
|
202
|
+
f"{question['slug']}: {saved if saved not in (None, [], ()) else 'No answer selected'}"
|
|
203
|
+
)
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
return ui.card(
|
|
207
|
+
ui.h2("Review"),
|
|
208
|
+
ui.p("Submit the quiz when you are ready."),
|
|
209
|
+
question_overview_grid(questions, answers, index),
|
|
210
|
+
ui.tags.ul(*items),
|
|
211
|
+
ui.row(
|
|
212
|
+
ui.column(6, ui.input_action_button("prev_question", "Previous question")),
|
|
213
|
+
ui.column(6, ui.input_action_button("submit_quiz", "Submit and grade")),
|
|
214
|
+
),
|
|
215
|
+
class_="mcqpy-card mcqpy-math",
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def render_question_card(loaded: dict, answers: dict, index: int, title: str):
|
|
220
|
+
questions = loaded["questions"]
|
|
221
|
+
question = questions[index]
|
|
222
|
+
progress = f"Question {index + 1} of {len(questions)}"
|
|
223
|
+
question_kind = (
|
|
224
|
+
"Single answer" if question["question_type"] == "single" else "Multiple answers"
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
body = [
|
|
228
|
+
ui.h2(loaded.get("metadata", {}).get("title", title)),
|
|
229
|
+
ui.p(progress, class_="text-muted"),
|
|
230
|
+
ui.row(
|
|
231
|
+
ui.column(
|
|
232
|
+
8,
|
|
233
|
+
ui.input_slider(
|
|
234
|
+
"jump_slider",
|
|
235
|
+
"Jump to question",
|
|
236
|
+
min=1,
|
|
237
|
+
max=len(questions) + 1,
|
|
238
|
+
value=index + 1,
|
|
239
|
+
step=1,
|
|
240
|
+
width="100%",
|
|
241
|
+
),
|
|
242
|
+
),
|
|
243
|
+
ui.column(
|
|
244
|
+
4,
|
|
245
|
+
ui.input_numeric(
|
|
246
|
+
"jump_number",
|
|
247
|
+
"Question number",
|
|
248
|
+
value=index + 1,
|
|
249
|
+
min=1,
|
|
250
|
+
max=len(questions) + 1,
|
|
251
|
+
width="100%",
|
|
252
|
+
),
|
|
253
|
+
),
|
|
254
|
+
class_="mcqpy-jump-row",
|
|
255
|
+
),
|
|
256
|
+
ui.h3(question["slug"]),
|
|
257
|
+
ui.p(
|
|
258
|
+
f"{question_kind} • {question['point_value']} point"
|
|
259
|
+
f"{'' if question['point_value'] == 1 else 's'}",
|
|
260
|
+
class_="mcqpy-question-meta",
|
|
261
|
+
),
|
|
262
|
+
ui.div(ui.markdown(question["text"]), class_="mcqpy-question-text"),
|
|
263
|
+
ui.div(*render_question_media(question), class_="mcqpy-media"),
|
|
264
|
+
ui.div(question_answer_ui(question, index), class_="mcqpy-answer-group"),
|
|
265
|
+
ui.row(
|
|
266
|
+
ui.column(4, ui.input_action_button("prev_question", "Previous")),
|
|
267
|
+
ui.column(4, ui.input_action_button("next_question", "Next")),
|
|
268
|
+
ui.column(4, ui.div()),
|
|
269
|
+
class_="mcqpy-nav-row",
|
|
270
|
+
),
|
|
271
|
+
question_overview_grid(questions, answers, index),
|
|
272
|
+
]
|
|
273
|
+
return ui.card(*body, class_="mcqpy-card mcqpy-math")
|