PyKubeGrader 0.0.4__py3-none-any.whl
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.
- PyKubeGrader-0.0.4.dist-info/LICENSE.txt +28 -0
- PyKubeGrader-0.0.4.dist-info/METADATA +69 -0
- PyKubeGrader-0.0.4.dist-info/RECORD +17 -0
- PyKubeGrader-0.0.4.dist-info/WHEEL +5 -0
- PyKubeGrader-0.0.4.dist-info/top_level.txt +1 -0
- pykubegrader/__init__.py +16 -0
- pykubegrader/widgets/info_widget.py +108 -0
- pykubegrader/widgets/mc_widget.py +72 -0
- pykubegrader/widgets/misc.py +29 -0
- pykubegrader/widgets/multi_select_base.py +99 -0
- pykubegrader/widgets/reading_base.py +168 -0
- pykubegrader/widgets/reading_widget.py +84 -0
- pykubegrader/widgets/select_base.py +69 -0
- pykubegrader/widgets/select_many_widget.py +101 -0
- pykubegrader/widgets/telemetry.py +132 -0
- pykubegrader/widgets/types_widget.py +77 -0
- pykubegrader/widgets/validate.py +311 -0
@@ -0,0 +1,28 @@
|
|
1
|
+
BSD 3-Clause License
|
2
|
+
|
3
|
+
Copyright (c) 2024, M3-Learning: Multifunctional Materials and Machine Learning
|
4
|
+
|
5
|
+
Redistribution and use in source and binary forms, with or without
|
6
|
+
modification, are permitted provided that the following conditions are met:
|
7
|
+
|
8
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
9
|
+
list of conditions and the following disclaimer.
|
10
|
+
|
11
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
12
|
+
this list of conditions and the following disclaimer in the documentation
|
13
|
+
and/or other materials provided with the distribution.
|
14
|
+
|
15
|
+
3. Neither the name of the copyright holder nor the names of its
|
16
|
+
contributors may be used to endorse or promote products derived from
|
17
|
+
this software without specific prior written permission.
|
18
|
+
|
19
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
20
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
21
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
22
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
23
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
24
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
25
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
26
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
27
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
28
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
@@ -0,0 +1,69 @@
|
|
1
|
+
Metadata-Version: 2.1
|
2
|
+
Name: PyKubeGrader
|
3
|
+
Version: 0.0.4
|
4
|
+
Summary: Add a short description here!
|
5
|
+
Home-page: https://github.com/pyscaffold/pyscaffold/
|
6
|
+
Author: jagar2
|
7
|
+
Author-email: jca92@drexel.edu
|
8
|
+
License: MIT
|
9
|
+
Project-URL: Documentation, https://pyscaffold.org/
|
10
|
+
Platform: any
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
12
|
+
Classifier: Programming Language :: Python
|
13
|
+
Description-Content-Type: text/x-rst; charset=UTF-8
|
14
|
+
License-File: LICENSE.txt
|
15
|
+
Requires-Dist: importlib-metadata; python_version < "3.8"
|
16
|
+
Provides-Extra: testing
|
17
|
+
Requires-Dist: setuptools; extra == "testing"
|
18
|
+
Requires-Dist: pytest; extra == "testing"
|
19
|
+
Requires-Dist: pytest-cov; extra == "testing"
|
20
|
+
|
21
|
+
.. These are examples of badges you might want to add to your README:
|
22
|
+
please update the URLs accordingly
|
23
|
+
|
24
|
+
.. image:: https://api.cirrus-ci.com/github/<USER>/PyKubeGrader.svg?branch=main
|
25
|
+
:alt: Built Status
|
26
|
+
:target: https://cirrus-ci.com/github/<USER>/PyKubeGrader
|
27
|
+
.. image:: https://readthedocs.org/projects/PyKubeGrader/badge/?version=latest
|
28
|
+
:alt: ReadTheDocs
|
29
|
+
:target: https://PyKubeGrader.readthedocs.io/en/stable/
|
30
|
+
.. image:: https://img.shields.io/coveralls/github/<USER>/PyKubeGrader/main.svg
|
31
|
+
:alt: Coveralls
|
32
|
+
:target: https://coveralls.io/r/<USER>/PyKubeGrader
|
33
|
+
.. image:: https://img.shields.io/pypi/v/PyKubeGrader.svg
|
34
|
+
:alt: PyPI-Server
|
35
|
+
:target: https://pypi.org/project/PyKubeGrader/
|
36
|
+
.. image:: https://img.shields.io/conda/vn/conda-forge/PyKubeGrader.svg
|
37
|
+
:alt: Conda-Forge
|
38
|
+
:target: https://anaconda.org/conda-forge/PyKubeGrader
|
39
|
+
.. image:: https://pepy.tech/badge/PyKubeGrader/month
|
40
|
+
:alt: Monthly Downloads
|
41
|
+
:target: https://pepy.tech/project/PyKubeGrader
|
42
|
+
.. image:: https://img.shields.io/twitter/url/http/shields.io.svg?style=social&label=Twitter
|
43
|
+
:alt: Twitter
|
44
|
+
:target: https://twitter.com/PyKubeGrader
|
45
|
+
|
46
|
+
.. image:: https://img.shields.io/badge/-PyScaffold-005CA0?logo=pyscaffold
|
47
|
+
:alt: Project generated with PyScaffold
|
48
|
+
:target: https://pyscaffold.org/
|
49
|
+
|
50
|
+
|
|
51
|
+
|
52
|
+
============
|
53
|
+
PyKubeGrader
|
54
|
+
============
|
55
|
+
|
56
|
+
|
57
|
+
Add a short description here!
|
58
|
+
|
59
|
+
|
60
|
+
A longer description of your project goes here...
|
61
|
+
|
62
|
+
|
63
|
+
.. _pyscaffold-notes:
|
64
|
+
|
65
|
+
Note
|
66
|
+
====
|
67
|
+
|
68
|
+
This project has been set up using PyScaffold 4.6. For details and usage
|
69
|
+
information on PyScaffold see https://pyscaffold.org/.
|
@@ -0,0 +1,17 @@
|
|
1
|
+
pykubegrader/__init__.py,sha256=JHme-EVdE2QbS9_Q2qdHiy5bEsyeQDbZ8aP2OfoSyiw,583
|
2
|
+
pykubegrader/widgets/info_widget.py,sha256=x73heTqmqJQ9XkaE3eW4eLXTGJlmmTdGEkGtbIcY7uY,3532
|
3
|
+
pykubegrader/widgets/mc_widget.py,sha256=eOFuZwEIc8NtgZS-Xnc9e49C1i0qYJvwFPyDVy7jq0c,2025
|
4
|
+
pykubegrader/widgets/misc.py,sha256=dKw6SyRYU3DWRgD3xER7wq-C9e1daWPkqr901LpcwiQ,642
|
5
|
+
pykubegrader/widgets/multi_select_base.py,sha256=GJQ-xFJTErar94cFwTxsSmLKPPmywJRUQELhAbv-uFs,3102
|
6
|
+
pykubegrader/widgets/reading_base.py,sha256=vSoyThl2vFbnbgB_LDc-DDRPWCM2xGbazEptDjNn4uA,5311
|
7
|
+
pykubegrader/widgets/reading_widget.py,sha256=e8lXSzGI9dHCxW7fIvwJUU6j8qWQgHnKR06ke2W1k7w,3022
|
8
|
+
pykubegrader/widgets/select_base.py,sha256=cyTWKYRbsGYsYJh6ntW2vPub4r15c8IgcsMFSCG3St4,2121
|
9
|
+
pykubegrader/widgets/select_many_widget.py,sha256=Rmb9Es42qzSdPfhU6ypcKnw_30PwuARX2UKVwgwbQhg,3772
|
10
|
+
pykubegrader/widgets/telemetry.py,sha256=3GhItJ9dw18XcFutfQEk4z-DllCVtKXCKKLesf_jxzQ,3194
|
11
|
+
pykubegrader/widgets/types_widget.py,sha256=FOrnR2x1eNM-pcFNIC8JnQdylgSUSUzuACmiIRuFTbQ,2244
|
12
|
+
pykubegrader/widgets/validate.py,sha256=guEj0yeu9A_-Ig6S8diOfaNEWxud3k7t4tI-i4Steak,10585
|
13
|
+
PyKubeGrader-0.0.4.dist-info/LICENSE.txt,sha256=YTp-Ewc8Kems8PJEE27KnBPFnZSxoWvSg7nnknzPyYw,1546
|
14
|
+
PyKubeGrader-0.0.4.dist-info/METADATA,sha256=iMrtcscGbzaKzGx_dDYNVrPG6XFcuNIGpuTTkjz2rrY,2357
|
15
|
+
PyKubeGrader-0.0.4.dist-info/WHEEL,sha256=PZUExdf71Ui_so67QXpySuHtCi3-J3wvF4ORK6k_S8U,91
|
16
|
+
PyKubeGrader-0.0.4.dist-info/top_level.txt,sha256=e550Klfze6higFxER1V62fnGOcIgiKRbsrl9CC4UdtQ,13
|
17
|
+
PyKubeGrader-0.0.4.dist-info/RECORD,,
|
@@ -0,0 +1 @@
|
|
1
|
+
pykubegrader
|
pykubegrader/__init__.py
ADDED
@@ -0,0 +1,16 @@
|
|
1
|
+
import sys
|
2
|
+
|
3
|
+
if sys.version_info[:2] >= (3, 8):
|
4
|
+
# TODO: Import directly (no need for conditional) when `python_requires = >= 3.8`
|
5
|
+
from importlib.metadata import PackageNotFoundError, version # pragma: no cover
|
6
|
+
else:
|
7
|
+
from importlib_metadata import PackageNotFoundError, version # pragma: no cover
|
8
|
+
|
9
|
+
try:
|
10
|
+
# Change here if project is renamed and does not equal the package name
|
11
|
+
dist_name = "PyKubeGrader"
|
12
|
+
__version__ = version(dist_name)
|
13
|
+
except PackageNotFoundError: # pragma: no cover
|
14
|
+
__version__ = "unknown"
|
15
|
+
finally:
|
16
|
+
del version, PackageNotFoundError
|
@@ -0,0 +1,108 @@
|
|
1
|
+
import os
|
2
|
+
import re
|
3
|
+
import socket
|
4
|
+
|
5
|
+
import numpy as np
|
6
|
+
import panel as pn
|
7
|
+
|
8
|
+
from .telemetry import ensure_responses, update_responses
|
9
|
+
|
10
|
+
EMAIL_PATTERN = re.compile(r"[a-z]+\d+@drexel\.edu")
|
11
|
+
|
12
|
+
KEYS = [
|
13
|
+
"first_name",
|
14
|
+
"last_name",
|
15
|
+
"drexel_id",
|
16
|
+
"drexel_email",
|
17
|
+
"hostname",
|
18
|
+
"ip_address",
|
19
|
+
"jupyter_user",
|
20
|
+
"seed",
|
21
|
+
]
|
22
|
+
|
23
|
+
|
24
|
+
class StudentInfoForm:
|
25
|
+
def __init__(self, **kwargs) -> None:
|
26
|
+
self.first_name = kwargs.get("first_name", "")
|
27
|
+
self.last_name = kwargs.get("last_name", "")
|
28
|
+
self.drexel_id = kwargs.get("drexel_id", "")
|
29
|
+
self.drexel_email = kwargs.get("drexel_email", "")
|
30
|
+
self.hostname = kwargs.get("hostname", "")
|
31
|
+
self.ip_address = kwargs.get("ip_address", "")
|
32
|
+
self.jupyter_user = kwargs.get("jupyter_user", "")
|
33
|
+
self.seed = kwargs.get("seed", np.random.randint(0, 100))
|
34
|
+
|
35
|
+
self.first_name_widget = pn.widgets.TextInput(
|
36
|
+
name="First Name", value=self.first_name
|
37
|
+
)
|
38
|
+
self.last_name_widget = pn.widgets.TextInput(
|
39
|
+
name="Last Name", value=self.last_name
|
40
|
+
)
|
41
|
+
self.drexel_id_widget = pn.widgets.TextInput(
|
42
|
+
name="Drexel ID", value=self.drexel_id
|
43
|
+
)
|
44
|
+
self.drexel_email_widget = pn.widgets.TextInput(
|
45
|
+
name="Drexel Email", value=self.drexel_email
|
46
|
+
)
|
47
|
+
|
48
|
+
self.submit_button = pn.widgets.Button(
|
49
|
+
name="Submit", button_type="primary", styles=dict(margin_top="1.5em")
|
50
|
+
)
|
51
|
+
self.submit_button.on_click(self.submit)
|
52
|
+
|
53
|
+
self.message = pn.pane.Str("") # Placeholder for status message
|
54
|
+
|
55
|
+
self.layout = pn.Column(
|
56
|
+
"# Student Information Form",
|
57
|
+
self.first_name_widget,
|
58
|
+
self.last_name_widget,
|
59
|
+
self.drexel_id_widget,
|
60
|
+
self.drexel_email_widget,
|
61
|
+
self.submit_button,
|
62
|
+
self.message,
|
63
|
+
)
|
64
|
+
|
65
|
+
def submit(self, _) -> None:
|
66
|
+
info = ensure_responses()
|
67
|
+
|
68
|
+
info["first_name"] = self.first_name_widget.value.strip()
|
69
|
+
info["last_name"] = self.last_name_widget.value.strip()
|
70
|
+
info["drexel_id"] = self.drexel_id_widget.value.strip()
|
71
|
+
info["drexel_email"] = self.drexel_email_widget.value.strip()
|
72
|
+
|
73
|
+
info["hostname"] = socket.gethostname()
|
74
|
+
info["jupyter_user"] = os.environ.get("JUPYTERHUB_USER", "Not on JupyterHub")
|
75
|
+
|
76
|
+
if "seed" not in info:
|
77
|
+
info["seed"] = np.random.randint(0, 100)
|
78
|
+
|
79
|
+
try:
|
80
|
+
info["ip_address"] = socket.gethostbyname(info["hostname"])
|
81
|
+
except socket.gaierror:
|
82
|
+
info["ip_address"] = "IP unavailable"
|
83
|
+
|
84
|
+
try:
|
85
|
+
for key in KEYS:
|
86
|
+
if info[key] == "":
|
87
|
+
raise ValueError(f"Missing form input: {key}")
|
88
|
+
|
89
|
+
if not EMAIL_PATTERN.fullmatch(info["drexel_email"]):
|
90
|
+
raise ValueError(f"Invalid email format: {info['drexel_email']}")
|
91
|
+
|
92
|
+
email_prefix = info["drexel_email"].split("@")[0]
|
93
|
+
if info["drexel_id"] != email_prefix:
|
94
|
+
raise ValueError(
|
95
|
+
f"Drexel ID {info['drexel_id']} does not match email {info['drexel_email']}"
|
96
|
+
)
|
97
|
+
|
98
|
+
for key in KEYS:
|
99
|
+
update_responses(key, info[key])
|
100
|
+
|
101
|
+
self.message.object = "Student info recorded successfully!"
|
102
|
+
self.message.style = {"color": "green"}
|
103
|
+
except ValueError as e:
|
104
|
+
self.message.object = str(e)
|
105
|
+
self.message.style = {"color": "red"}
|
106
|
+
|
107
|
+
def show(self):
|
108
|
+
return self.layout
|
@@ -0,0 +1,72 @@
|
|
1
|
+
from typing import Tuple
|
2
|
+
|
3
|
+
import panel as pn
|
4
|
+
|
5
|
+
from .misc import list_of_lists
|
6
|
+
from .select_base import SelectQuestion
|
7
|
+
|
8
|
+
|
9
|
+
def MCQ(
|
10
|
+
descriptions: list[str],
|
11
|
+
options: list[str] | list[list[str]],
|
12
|
+
initial_vals: list[str],
|
13
|
+
) -> Tuple[list[pn.pane.HTML], list[pn.widgets.RadioButtonGroup]]:
|
14
|
+
desc_width = "350px"
|
15
|
+
|
16
|
+
desc_widgets = [
|
17
|
+
pn.pane.HTML(
|
18
|
+
f"<div style='text-align: left; width: {desc_width};'><b>{desc}</b></div>"
|
19
|
+
)
|
20
|
+
for desc in descriptions
|
21
|
+
]
|
22
|
+
|
23
|
+
radio_buttons = [
|
24
|
+
pn.widgets.RadioButtonGroup(
|
25
|
+
options=option,
|
26
|
+
value=value,
|
27
|
+
width=300,
|
28
|
+
)
|
29
|
+
for value, option in zip(
|
30
|
+
initial_vals,
|
31
|
+
options if list_of_lists(options) else [options] * len(initial_vals),
|
32
|
+
)
|
33
|
+
]
|
34
|
+
|
35
|
+
return desc_widgets, radio_buttons
|
36
|
+
|
37
|
+
|
38
|
+
class MCQuestion(SelectQuestion):
|
39
|
+
def __init__(
|
40
|
+
self,
|
41
|
+
title="Select the option that matches the definition:",
|
42
|
+
style=MCQ,
|
43
|
+
question_number=2,
|
44
|
+
keys=["MC1", "MC2", "MC3", "MC4"],
|
45
|
+
options=[
|
46
|
+
["List", "Dictionary", "Tuple", "Set"],
|
47
|
+
["return", "continue", "pass", "break"],
|
48
|
+
["*", "^", "**", "//"],
|
49
|
+
[
|
50
|
+
"list.add(element)",
|
51
|
+
"list.append(element)",
|
52
|
+
"list.insert(element)",
|
53
|
+
"list.push(element)",
|
54
|
+
],
|
55
|
+
],
|
56
|
+
descriptions=[
|
57
|
+
"Which of the following stores key:value pairs?",
|
58
|
+
"The following condition returns to the next iteration of the loop",
|
59
|
+
"Which operator is used for exponentiation in Python?",
|
60
|
+
"Which method is used to add an element to the end of a list in Python?",
|
61
|
+
],
|
62
|
+
points=2,
|
63
|
+
):
|
64
|
+
super().__init__(
|
65
|
+
title=title,
|
66
|
+
style=style,
|
67
|
+
question_number=question_number,
|
68
|
+
keys=keys,
|
69
|
+
options=options,
|
70
|
+
descriptions=descriptions,
|
71
|
+
points=points,
|
72
|
+
)
|
@@ -0,0 +1,29 @@
|
|
1
|
+
import random
|
2
|
+
from typing import Tuple
|
3
|
+
|
4
|
+
import panel as pn
|
5
|
+
|
6
|
+
|
7
|
+
def list_of_lists(options: list) -> bool:
|
8
|
+
return all(isinstance(elem, list) for elem in options)
|
9
|
+
|
10
|
+
|
11
|
+
def shuffle_options(options, seed: int):
|
12
|
+
random.seed(seed)
|
13
|
+
random.shuffle(options)
|
14
|
+
|
15
|
+
return options
|
16
|
+
|
17
|
+
|
18
|
+
def shuffle_questions(
|
19
|
+
desc_widgets: list[pn.pane.HTML],
|
20
|
+
dropdowns: list[pn.widgets.Select] | list[pn.Column],
|
21
|
+
seed: int,
|
22
|
+
) -> list[Tuple[pn.pane.HTML, pn.widgets.Select | pn.Column]]:
|
23
|
+
random.seed(seed)
|
24
|
+
|
25
|
+
# Combine widgets into pairs
|
26
|
+
widget_pairs = list(zip(desc_widgets, dropdowns))
|
27
|
+
|
28
|
+
random.shuffle(widget_pairs)
|
29
|
+
return widget_pairs
|
@@ -0,0 +1,99 @@
|
|
1
|
+
from typing import Callable, Tuple
|
2
|
+
|
3
|
+
import panel as pn
|
4
|
+
|
5
|
+
from .misc import shuffle_questions
|
6
|
+
from .telemetry import ensure_responses, update_responses
|
7
|
+
|
8
|
+
|
9
|
+
class MultiSelectQuestion:
|
10
|
+
def __init__(
|
11
|
+
self,
|
12
|
+
title: str,
|
13
|
+
style: Callable[
|
14
|
+
[list[str], list[list[str]], list[bool]],
|
15
|
+
Tuple[list[pn.pane.HTML], list[pn.Column]],
|
16
|
+
],
|
17
|
+
question_number: int,
|
18
|
+
keys: list[str],
|
19
|
+
options: list[list[str]],
|
20
|
+
descriptions: list[str],
|
21
|
+
points: int,
|
22
|
+
):
|
23
|
+
responses = ensure_responses()
|
24
|
+
|
25
|
+
self.points = points
|
26
|
+
self.question_number = question_number
|
27
|
+
self.style = style
|
28
|
+
|
29
|
+
flat_index = 0
|
30
|
+
self.keys: list[str] = []
|
31
|
+
for i, _ in enumerate(keys):
|
32
|
+
for _ in options[i]:
|
33
|
+
flat_index += 1 # Start at 1
|
34
|
+
self.keys.append(f"q{question_number}_{flat_index}")
|
35
|
+
|
36
|
+
try:
|
37
|
+
seed: int = responses["seed"]
|
38
|
+
except ValueError:
|
39
|
+
raise ValueError(
|
40
|
+
"You must submit your student info before starting the exam"
|
41
|
+
)
|
42
|
+
|
43
|
+
# Dynamically assigning attributes based on keys, with default values from responses
|
44
|
+
for key in self.keys:
|
45
|
+
setattr(self, key, responses.get(key, False))
|
46
|
+
|
47
|
+
self.initial_vals = [getattr(self, key) for key in self.keys]
|
48
|
+
|
49
|
+
description_widgets, self.widgets = style(
|
50
|
+
descriptions, options, self.initial_vals
|
51
|
+
)
|
52
|
+
|
53
|
+
self.submit_button = pn.widgets.Button(name="Submit")
|
54
|
+
self.submit_button.on_click(self.submit)
|
55
|
+
|
56
|
+
widget_pairs = shuffle_questions(description_widgets, self.widgets, seed)
|
57
|
+
|
58
|
+
# Panel layout
|
59
|
+
question_header = pn.pane.HTML(
|
60
|
+
f"<h2>Question {self.question_number}: {title}</h2>"
|
61
|
+
)
|
62
|
+
question_body = pn.Column(
|
63
|
+
*[
|
64
|
+
pn.Row(desc_widget, checkbox_set)
|
65
|
+
for desc_widget, checkbox_set in widget_pairs
|
66
|
+
]
|
67
|
+
)
|
68
|
+
|
69
|
+
self.layout = pn.Column(question_header, question_body, self.submit_button)
|
70
|
+
|
71
|
+
def submit(self, _) -> None:
|
72
|
+
responses_flat: list[bool] = []
|
73
|
+
self.responses_nested: list[list[bool]] = []
|
74
|
+
|
75
|
+
for row in self.widgets:
|
76
|
+
next_selections = []
|
77
|
+
|
78
|
+
for widget in row.objects:
|
79
|
+
# Skip HTML widgets
|
80
|
+
if isinstance(widget, pn.pane.HTML):
|
81
|
+
continue
|
82
|
+
|
83
|
+
if isinstance(widget, pn.widgets.Checkbox):
|
84
|
+
next_selections.append(widget.value)
|
85
|
+
responses_flat.append(widget.value) # For flat list of responses
|
86
|
+
|
87
|
+
# Append all responses for this widget at once, forming a list of lists
|
88
|
+
self.responses_nested.append(next_selections)
|
89
|
+
|
90
|
+
self.record_responses(responses_flat)
|
91
|
+
|
92
|
+
def record_responses(self, responses_flat: list[bool]) -> None:
|
93
|
+
for key, value in zip(self.keys, responses_flat):
|
94
|
+
update_responses(key, value)
|
95
|
+
|
96
|
+
print("Responses recorded successfully")
|
97
|
+
|
98
|
+
def show(self):
|
99
|
+
return self.layout
|
@@ -0,0 +1,168 @@
|
|
1
|
+
import copy
|
2
|
+
from typing import Optional
|
3
|
+
|
4
|
+
import panel as pn
|
5
|
+
|
6
|
+
from .misc import shuffle_options
|
7
|
+
from .telemetry import ensure_responses, update_responses
|
8
|
+
|
9
|
+
|
10
|
+
class ReadingPython:
|
11
|
+
def __init__(
|
12
|
+
self,
|
13
|
+
title: str,
|
14
|
+
question_number: int,
|
15
|
+
options: dict,
|
16
|
+
) -> None:
|
17
|
+
# Load responses from JSON (or create file if it doesn't exist)
|
18
|
+
responses = ensure_responses()
|
19
|
+
|
20
|
+
self.question_number = question_number
|
21
|
+
|
22
|
+
default = None
|
23
|
+
|
24
|
+
# Dynamically assign attributes based on keys, with default values from responses
|
25
|
+
for num in range(len(options["lines_to_comment"]) + options["n_rows"]):
|
26
|
+
key = f"q{question_number}_{num+1}"
|
27
|
+
|
28
|
+
# Dynamically assign the value from the responses file for persistence
|
29
|
+
if num < len(options["lines_to_comment"]):
|
30
|
+
setattr(self, key, responses.get(key, default))
|
31
|
+
else:
|
32
|
+
setattr(
|
33
|
+
self,
|
34
|
+
key,
|
35
|
+
responses.get(key, [default] * (len(options["table_headers"]) - 1)),
|
36
|
+
)
|
37
|
+
|
38
|
+
# Checks that a seed was assigned to responses
|
39
|
+
try:
|
40
|
+
seed: int = responses["seed"]
|
41
|
+
except ValueError:
|
42
|
+
raise ValueError(
|
43
|
+
"You must submit your student info before starting the exam"
|
44
|
+
)
|
45
|
+
|
46
|
+
#
|
47
|
+
# Question title
|
48
|
+
#
|
49
|
+
|
50
|
+
question_title = pn.pane.HTML(f"<h2>Question {question_number}: {title}</h2>")
|
51
|
+
|
52
|
+
#
|
53
|
+
# Comment dropdowns
|
54
|
+
#
|
55
|
+
|
56
|
+
self.dropdowns_for_comments: dict[str, pn.widgets.Select] = {
|
57
|
+
line: pn.widgets.Select(
|
58
|
+
options=shuffle_options(options["comments_options"], seed),
|
59
|
+
name=f"Line {line}:",
|
60
|
+
value=getattr(self, f"q{question_number}_{i_comments+1}"),
|
61
|
+
width=600,
|
62
|
+
)
|
63
|
+
for i_comments, line in enumerate(options["lines_to_comment"])
|
64
|
+
}
|
65
|
+
|
66
|
+
comment_dropdowns = pn.Column(*self.dropdowns_for_comments.values())
|
67
|
+
|
68
|
+
#
|
69
|
+
# Execution dropdowns
|
70
|
+
#
|
71
|
+
|
72
|
+
# Instructions
|
73
|
+
execution_instructions = pn.pane.HTML(
|
74
|
+
"<h3>For each step, select the appropriate response:</h3>"
|
75
|
+
)
|
76
|
+
|
77
|
+
# Header row
|
78
|
+
header_row = pn.Row(
|
79
|
+
*[
|
80
|
+
pn.pane.HTML(f"<strong>{header}</strong>", width=150)
|
81
|
+
for header in options["table_headers"]
|
82
|
+
]
|
83
|
+
)
|
84
|
+
|
85
|
+
# Make a deep copy of the lines to comment and add a null value to the beginning
|
86
|
+
# This is to provide the null response to the question
|
87
|
+
line_comment: list[int | str] = copy.deepcopy(options["lines_to_comment"])
|
88
|
+
line_comment.insert(0, "")
|
89
|
+
|
90
|
+
dropdown_options = [
|
91
|
+
line_comment,
|
92
|
+
options["variables_changed"],
|
93
|
+
options["current_values"],
|
94
|
+
options["datatypes"],
|
95
|
+
]
|
96
|
+
|
97
|
+
# Function to create a row with dropdowns
|
98
|
+
def create_row(step: int) -> pn.Row:
|
99
|
+
widgets_list = [
|
100
|
+
pn.pane.HTML(f"Step {step+1}", width=150)
|
101
|
+
if i == 0
|
102
|
+
else pn.widgets.Select(
|
103
|
+
options=dropdown_options[i - 1],
|
104
|
+
value=getattr(
|
105
|
+
self,
|
106
|
+
f'q{question_number}_{len(options["lines_to_comment"])+step+1}',
|
107
|
+
)[i - 1],
|
108
|
+
width=150,
|
109
|
+
)
|
110
|
+
for i in range(len(options["table_headers"]))
|
111
|
+
]
|
112
|
+
|
113
|
+
return pn.Row(*widgets_list)
|
114
|
+
|
115
|
+
# Generate rows dynamically based on n_rows
|
116
|
+
self.rows = [create_row(step) for step in range(options["n_rows"])]
|
117
|
+
|
118
|
+
# Combine header and rows
|
119
|
+
execution_steps = pn.Column(header_row, *self.rows)
|
120
|
+
|
121
|
+
# Submit button
|
122
|
+
self.submit_button = pn.widgets.Button(name="Submit")
|
123
|
+
self.submit_button.on_click(self.submit)
|
124
|
+
|
125
|
+
# Combine everything into a single layout
|
126
|
+
self.layout = pn.Column(
|
127
|
+
question_title,
|
128
|
+
comment_dropdowns,
|
129
|
+
execution_instructions,
|
130
|
+
execution_steps,
|
131
|
+
self.submit_button,
|
132
|
+
)
|
133
|
+
|
134
|
+
def submit(self, _) -> None:
|
135
|
+
# Get section 1 responses
|
136
|
+
self.output_comments: list[str] = []
|
137
|
+
for out in self.dropdowns_for_comments.values():
|
138
|
+
self.output_comments.append(out.value if isinstance(out.value, str) else "")
|
139
|
+
|
140
|
+
# Get section 2 responses
|
141
|
+
self.output_execution: list[list[Optional[str | int]]] = []
|
142
|
+
|
143
|
+
for row in self.rows[:]:
|
144
|
+
row_value: list[Optional[str | int]] = []
|
145
|
+
|
146
|
+
for box in row.objects:
|
147
|
+
if isinstance(box, pn.widgets.Select):
|
148
|
+
row_value.append(box.value)
|
149
|
+
|
150
|
+
if not any(row_value):
|
151
|
+
continue
|
152
|
+
|
153
|
+
self.output_execution.append(row_value)
|
154
|
+
|
155
|
+
# Persist responses to JSON
|
156
|
+
i = 0
|
157
|
+
for comment_val in self.output_comments:
|
158
|
+
i += 1
|
159
|
+
update_responses(f"q{self.question_number}_{i}", comment_val)
|
160
|
+
|
161
|
+
for exec_val in self.output_execution:
|
162
|
+
i += 1
|
163
|
+
update_responses(f"q{self.question_number}_{i}", exec_val)
|
164
|
+
|
165
|
+
print("Responses recorded successfully")
|
166
|
+
|
167
|
+
def show(self):
|
168
|
+
return self.layout
|