PyKubeGrader 0.0.4__py3-none-any.whl
Sign up to get free protection for your applications and to get access to all the features.
- 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
|