Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add new question type for DoenetML #458

Draft
wants to merge 8 commits into
base: main
Choose a base branch
from
62 changes: 62 additions & 0 deletions bases/rsptx/book_server_api/routers/assessment.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@
# -------------------
from bleach import clean
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.responses import JSONResponse
from fastapi.encoders import jsonable_encoder
from pydantic import BaseModel

# Local application imports
Expand Down Expand Up @@ -67,6 +69,66 @@
tags=["assessment"],
)

@router.get("/getDoenetState")
async def getdoenetstate(request: Request, div_id: str,
course_name: str, event: str,
# sid: Optional[str],
user=Depends(auth_manager)
):
request_data = AssessmentRequest(course=course_name, div_id=div_id, event=event)
# if the user is not logged in an HTTP 401 will be returned.
# Otherwise if the user is an instructor then use the provided
# sid (it could be any student in the class). If none is provided then
# use the user objects username
sid = user.username
if await is_instructor(request):
if request_data.sid:
sid = request_data.sid
else:
if request_data.sid:
# someone is attempting to spoof the api
return make_json_response(
status=status.HTTP_401_UNAUTHORIZED, detail="not an instructor"
)
request_data.sid = sid


row = await fetch_last_answer_table_entry(request_data)
# mypy complains that ``row.id`` doesn't exist (true, but the return type wasn't exact and this does exist).
if not row or row.id is None: # type: ignore
return JSONResponse(
status_code=200, content=jsonable_encoder({"loadedState": False, "success": True})
)
ret = row.dict()
rslogger.debug(f"row is {ret}")
if "timestamp" in ret:
ret["timestamp"] = (
ret["timestamp"].replace(tzinfo=datetime.timezone.utc).isoformat()
)
rslogger.debug(f"timestamp is {ret['timestamp']}")

# Do server-side grading if needed, which restores the answer and feedback.
if feedback := await is_server_feedback(request_data.div_id, request_data.course):
rcd = runestone_component_dict[EVENT2TABLE[request_data.event]]
# The grader should also be defined if there's feedback.
assert rcd.grader
# Use the grader to add server-side feedback to the returned dict.
ret.update(await rcd.grader(row, feedback))

# get grade and instructor feedback if Any
grades = await fetch_question_grade(sid, request_data.course, request_data.div_id)
if grades:
ret["comment"] = grades.comment
ret["score"] = grades.score

real_ret = ret["answer"]["state"]
real_ret["success"] = True
real_ret["loadedState"] = True
rslogger.debug(f"Returning {ret}")
# return make_json_response(detail=ret)
return JSONResponse(
status_code=200, content=jsonable_encoder(real_ret)
)

# getAssessResults
# ----------------
Expand Down
2 changes: 1 addition & 1 deletion bases/rsptx/book_server_api/routers/rslogging.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ async def log_book_event(
if entry.act in ["start", "pause", "resume"]:
# We don't need these in the answer table but want the event to be timedExam.
create_answer_table = False
elif entry.event == "webwork" or entry.event == "hparsonsAnswer":
elif entry.event == "webwork" or entry.event == "hparsonsAnswer" or entry.event == "doenet":
entry.answer = json.loads(useinfo_dict["answer"])

if create_answer_table:
Expand Down
3 changes: 3 additions & 0 deletions bases/rsptx/interactives/runestone/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from .datafile import DataFile
from .disqus import DisqusDirective
from .dragndrop import DragNDrop
from .doenet import DoenetDirective
from .fitb import FillInTheBlank
from .groupsub import GroupSubmission
from .hparsons import HParsonsDirective
Expand Down Expand Up @@ -40,6 +41,7 @@


# TODO: clean up - many of the folders are not needed as the files are imported by webpack
# TODO - Jason second's this TODO, I've been confused by duplicates copies of static assets
#
# runestone_static_dirs()
# -----------------------
Expand Down Expand Up @@ -251,6 +253,7 @@ def build(options):
"datafile": DataFile,
"disqus": DisqusDirective,
"dragndrop": DragNDrop,
"doenet": DoenetDirective,
"groupsub": GroupSubmission,
"hparsons": HParsonsDirective,
"parsonsprob": ParsonsProblem,
Expand Down
1 change: 1 addition & 0 deletions bases/rsptx/interactives/runestone/doenet/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from .doenet import *
181 changes: 181 additions & 0 deletions bases/rsptx/interactives/runestone/doenet/doenet.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@

# *********
# |docname|
# *********
# Copyright (C) 2011 Bradley N. Miller
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
__author__ = "jaltekruse"

from docutils import nodes
from docutils.parsers.rst import directives
from sqlalchemy import Table
from runestone.server.componentdb import (
addQuestionToDB,
addHTMLToDB,
maybeAddToAssignment,
)
from runestone.common.runestonedirective import (
RunestoneIdDirective,
RunestoneIdNode,
)


def setup(app):
app.add_directive("doenet", DoenetDirective)
app.add_node(DoenetNode, html=(visit_hp_html, depart_hp_html))

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For clarity, should probably rename all "hp" and "hparsons" references.



TEMPLATE_START = """
<div class="runestone">
<div data-component="hparsons" id=%(divid)s data-question_label="%(question_label)s" class="alert alert-warning hparsons_section">
<div class="hp_question">
"""

TEMPLATE_END = """
</div>
<div class='hparsons'></div>
<textarea
%(language)s
%(optional)s
%(dburl)s
%(reuse)s
%(randomize)s
%(blockanswer)s
style="visibility: hidden;">
%(initialsetting)s
</textarea>
</div>
</div>
"""


class DoenetNode(nodes.General, nodes.Element, RunestoneIdNode):
pass


# self for these functions is an instance of the writer class. For example
# in html, self is sphinx.writers.html.SmartyPantsHTMLTranslator
# The node that is passed as a parameter is an instance of our node class.
def visit_hp_html(self, node):

node["delimiter"] = "_start__{}_".format(node["runestone_options"]["divid"])

self.body.append(node["delimiter"])

res = TEMPLATE_START % node["runestone_options"]
self.body.append(res)


def depart_hp_html(self, node):
res = TEMPLATE_END % node["runestone_options"]
self.body.append(res)

addHTMLToDB(
node["runestone_options"]["divid"],
node["runestone_options"]["basecourse"],
"".join(self.body[self.body.index(node["delimiter"]) + 1 :]),
)

self.body.remove(node["delimiter"])


class DoenetDirective(RunestoneIdDirective):
"""
<!-- .. doenet:: doenet-1
-->
1+3000=<answer>4</answer>
"""

required_arguments = 1
optional_arguments = 1
has_content = True
option_spec = RunestoneIdDirective.option_spec.copy()
option_spec.update(
{
"dburl": directives.unchanged,
"language": directives.unchanged,
"reuse": directives.flag,
"randomize": directives.flag,
"blockanswer": directives.unchanged,
}
)

def run(self):
super(DoenetDirective, self).run()
addQuestionToDB(self)

env = self.state.document.settings.env

if "language" in self.options:
self.options["language"] = "data-language='{}'".format(
self.options["language"]
)
else:
self.options["language"] = ""

if "reuse" in self.options:
self.options["reuse"] = ' data-reuse="true"'
else:
self.options["reuse"] = ""

if "randomize" in self.options:
self.options["randomize"] = ' data-randomize="true"'
else:
self.options["randomize"] = ""

if "blockanswer" in self.options:
self.options["blockanswer"] = "data-blockanswer='{}'".format(
self.options["blockanswer"]
)
else:
self.options["blockanswer"] = ""

explain_text = None
if self.content:
if "~~~~" in self.content:
idx = self.content.index("~~~~")
explain_text = self.content[:idx]
self.content = self.content[idx + 1 :]
source = "\n".join(self.content)
else:
source = "\n"

self.explain_text = explain_text or ["Not an Exercise"]

self.options["initialsetting"] = source

# SQL Options
if "dburl" in self.options:
self.options["dburl"] = "data-dburl='{}'".format(self.options["dburl"])
else:
self.options["dburl"] = ""

course_name = env.config.html_context["course_id"]
divid = self.options["divid"]

hpnode = DoenetNode()
hpnode["runestone_options"] = self.options
hpnode["source"], hpnode["line"] = self.state_machine.get_source_and_line(
self.lineno
)
self.add_name(hpnode) # make this divid available as a target for :ref:

maybeAddToAssignment(self)
if explain_text:
self.updateContent()
self.state.nested_parse(explain_text, self.content_offset, hpnode)

return [hpnode]
Loading