Skip to content

Commit 2a63dc1

Browse files
committed
Add new question type for DoenetML
1 parent 7d76814 commit 2a63dc1

File tree

19 files changed

+565
-13
lines changed

19 files changed

+565
-13
lines changed

bases/rsptx/book_server_api/routers/assessment.py

+62
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323
# -------------------
2424
from bleach import clean
2525
from fastapi import APIRouter, Depends, HTTPException, Request, status
26+
from fastapi.responses import JSONResponse
27+
from fastapi.encoders import jsonable_encoder
2628
from pydantic import BaseModel
2729

2830
# Local application imports
@@ -67,6 +69,66 @@
6769
tags=["assessment"],
6870
)
6971

72+
@router.get("/getDoenetState")
73+
async def getdoenetstate(request: Request, div_id: str,
74+
course_name: str, event: str,
75+
# sid: Optional[str],
76+
user=Depends(auth_manager)
77+
):
78+
request_data = AssessmentRequest(course=course_name, div_id=div_id, event=event)
79+
# if the user is not logged in an HTTP 401 will be returned.
80+
# Otherwise if the user is an instructor then use the provided
81+
# sid (it could be any student in the class). If none is provided then
82+
# use the user objects username
83+
sid = user.username
84+
if await is_instructor(request):
85+
if request_data.sid:
86+
sid = request_data.sid
87+
else:
88+
if request_data.sid:
89+
# someone is attempting to spoof the api
90+
return make_json_response(
91+
status=status.HTTP_401_UNAUTHORIZED, detail="not an instructor"
92+
)
93+
request_data.sid = sid
94+
95+
96+
row = await fetch_last_answer_table_entry(request_data)
97+
# mypy complains that ``row.id`` doesn't exist (true, but the return type wasn't exact and this does exist).
98+
if not row or row.id is None: # type: ignore
99+
return JSONResponse(
100+
status_code=200, content=jsonable_encoder({"loadedState": False, "success": True})
101+
)
102+
ret = row.dict()
103+
rslogger.debug(f"row is {ret}")
104+
if "timestamp" in ret:
105+
ret["timestamp"] = (
106+
ret["timestamp"].replace(tzinfo=datetime.timezone.utc).isoformat()
107+
)
108+
rslogger.debug(f"timestamp is {ret['timestamp']}")
109+
110+
# Do server-side grading if needed, which restores the answer and feedback.
111+
if feedback := await is_server_feedback(request_data.div_id, request_data.course):
112+
rcd = runestone_component_dict[EVENT2TABLE[request_data.event]]
113+
# The grader should also be defined if there's feedback.
114+
assert rcd.grader
115+
# Use the grader to add server-side feedback to the returned dict.
116+
ret.update(await rcd.grader(row, feedback))
117+
118+
# get grade and instructor feedback if Any
119+
grades = await fetch_question_grade(sid, request_data.course, request_data.div_id)
120+
if grades:
121+
ret["comment"] = grades.comment
122+
ret["score"] = grades.score
123+
124+
real_ret = ret["answer"]["state"]
125+
real_ret["success"] = True
126+
real_ret["loadedState"] = True
127+
rslogger.debug(f"Returning {ret}")
128+
# return make_json_response(detail=ret)
129+
return JSONResponse(
130+
status_code=200, content=jsonable_encoder(real_ret)
131+
)
70132

71133
# getAssessResults
72134
# ----------------

bases/rsptx/book_server_api/routers/rslogging.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,7 @@ async def log_book_event(
149149
if entry.act in ["start", "pause", "resume"]:
150150
# We don't need these in the answer table but want the event to be timedExam.
151151
create_answer_table = False
152-
elif entry.event == "webwork" or entry.event == "hparsonsAnswer":
152+
elif entry.event == "webwork" or entry.event == "hparsonsAnswer" or entry.event == "doenet":
153153
entry.answer = json.loads(useinfo_dict["answer"])
154154

155155
if create_answer_table:

bases/rsptx/interactives/runestone/__init__.py

+3
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from .datafile import DataFile
1212
from .disqus import DisqusDirective
1313
from .dragndrop import DragNDrop
14+
from .doenet import DoenetDirective
1415
from .fitb import FillInTheBlank
1516
from .groupsub import GroupSubmission
1617
from .hparsons import HParsonsDirective
@@ -40,6 +41,7 @@
4041

4142

4243
# TODO: clean up - many of the folders are not needed as the files are imported by webpack
44+
# TODO - Jason second's this TODO, I've been confused by duplicates copies of static assets
4345
#
4446
# runestone_static_dirs()
4547
# -----------------------
@@ -251,6 +253,7 @@ def build(options):
251253
"datafile": DataFile,
252254
"disqus": DisqusDirective,
253255
"dragndrop": DragNDrop,
256+
"doenet": DoenetDirective,
254257
"groupsub": GroupSubmission,
255258
"hparsons": HParsonsDirective,
256259
"parsonsprob": ParsonsProblem,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from .doenet import *
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
2+
# *********
3+
# |docname|
4+
# *********
5+
# Copyright (C) 2011 Bradley N. Miller
6+
#
7+
# This program is free software: you can redistribute it and/or modify
8+
# it under the terms of the GNU General Public License as published by
9+
# the Free Software Foundation, either version 3 of the License, or
10+
# (at your option) any later version.
11+
#
12+
# This program is distributed in the hope that it will be useful,
13+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
14+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15+
# GNU General Public License for more details.
16+
#
17+
# You should have received a copy of the GNU General Public License
18+
# along with this program. If not, see <http://www.gnu.org/licenses/>.
19+
#
20+
__author__ = "jaltekruse"
21+
22+
from docutils import nodes
23+
from docutils.parsers.rst import directives
24+
from sqlalchemy import Table
25+
from runestone.server.componentdb import (
26+
addQuestionToDB,
27+
addHTMLToDB,
28+
maybeAddToAssignment,
29+
)
30+
from runestone.common.runestonedirective import (
31+
RunestoneIdDirective,
32+
RunestoneIdNode,
33+
)
34+
35+
36+
def setup(app):
37+
app.add_directive("doenet", DoenetDirective)
38+
app.add_node(DoenetNode, html=(visit_hp_html, depart_hp_html))
39+
40+
41+
TEMPLATE_START = """
42+
<div class="runestone">
43+
<div data-component="hparsons" id=%(divid)s data-question_label="%(question_label)s" class="alert alert-warning hparsons_section">
44+
<div class="hp_question">
45+
"""
46+
47+
TEMPLATE_END = """
48+
</div>
49+
<div class='hparsons'></div>
50+
<textarea
51+
%(language)s
52+
%(optional)s
53+
%(dburl)s
54+
%(reuse)s
55+
%(randomize)s
56+
%(blockanswer)s
57+
style="visibility: hidden;">
58+
%(initialsetting)s
59+
</textarea>
60+
</div>
61+
</div>
62+
"""
63+
64+
65+
class DoenetNode(nodes.General, nodes.Element, RunestoneIdNode):
66+
pass
67+
68+
69+
# self for these functions is an instance of the writer class. For example
70+
# in html, self is sphinx.writers.html.SmartyPantsHTMLTranslator
71+
# The node that is passed as a parameter is an instance of our node class.
72+
def visit_hp_html(self, node):
73+
74+
node["delimiter"] = "_start__{}_".format(node["runestone_options"]["divid"])
75+
76+
self.body.append(node["delimiter"])
77+
78+
res = TEMPLATE_START % node["runestone_options"]
79+
self.body.append(res)
80+
81+
82+
def depart_hp_html(self, node):
83+
res = TEMPLATE_END % node["runestone_options"]
84+
self.body.append(res)
85+
86+
addHTMLToDB(
87+
node["runestone_options"]["divid"],
88+
node["runestone_options"]["basecourse"],
89+
"".join(self.body[self.body.index(node["delimiter"]) + 1 :]),
90+
)
91+
92+
self.body.remove(node["delimiter"])
93+
94+
95+
class DoenetDirective(RunestoneIdDirective):
96+
"""
97+
<!-- .. doenet:: doenet-1
98+
-->
99+
1+3000=<answer>4</answer>
100+
"""
101+
102+
required_arguments = 1
103+
optional_arguments = 1
104+
has_content = True
105+
option_spec = RunestoneIdDirective.option_spec.copy()
106+
option_spec.update(
107+
{
108+
"dburl": directives.unchanged,
109+
"language": directives.unchanged,
110+
"reuse": directives.flag,
111+
"randomize": directives.flag,
112+
"blockanswer": directives.unchanged,
113+
}
114+
)
115+
116+
def run(self):
117+
super(DoenetDirective, self).run()
118+
addQuestionToDB(self)
119+
120+
env = self.state.document.settings.env
121+
122+
if "language" in self.options:
123+
self.options["language"] = "data-language='{}'".format(
124+
self.options["language"]
125+
)
126+
else:
127+
self.options["language"] = ""
128+
129+
if "reuse" in self.options:
130+
self.options["reuse"] = ' data-reuse="true"'
131+
else:
132+
self.options["reuse"] = ""
133+
134+
if "randomize" in self.options:
135+
self.options["randomize"] = ' data-randomize="true"'
136+
else:
137+
self.options["randomize"] = ""
138+
139+
if "blockanswer" in self.options:
140+
self.options["blockanswer"] = "data-blockanswer='{}'".format(
141+
self.options["blockanswer"]
142+
)
143+
else:
144+
self.options["blockanswer"] = ""
145+
146+
explain_text = None
147+
if self.content:
148+
if "~~~~" in self.content:
149+
idx = self.content.index("~~~~")
150+
explain_text = self.content[:idx]
151+
self.content = self.content[idx + 1 :]
152+
source = "\n".join(self.content)
153+
else:
154+
source = "\n"
155+
156+
self.explain_text = explain_text or ["Not an Exercise"]
157+
158+
self.options["initialsetting"] = source
159+
160+
# SQL Options
161+
if "dburl" in self.options:
162+
self.options["dburl"] = "data-dburl='{}'".format(self.options["dburl"])
163+
else:
164+
self.options["dburl"] = ""
165+
166+
course_name = env.config.html_context["course_id"]
167+
divid = self.options["divid"]
168+
169+
hpnode = DoenetNode()
170+
hpnode["runestone_options"] = self.options
171+
hpnode["source"], hpnode["line"] = self.state_machine.get_source_and_line(
172+
self.lineno
173+
)
174+
self.add_name(hpnode) # make this divid available as a target for :ref:
175+
176+
maybeAddToAssignment(self)
177+
if explain_text:
178+
self.updateContent()
179+
self.state.nested_parse(explain_text, self.content_offset, hpnode)
180+
181+
return [hpnode]

0 commit comments

Comments
 (0)