diff --git a/bases/rsptx/book_server_api/routers/assessment.py b/bases/rsptx/book_server_api/routers/assessment.py index 7f426d718..bb1a07acf 100644 --- a/bases/rsptx/book_server_api/routers/assessment.py +++ b/bases/rsptx/book_server_api/routers/assessment.py @@ -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 @@ -67,6 +69,23 @@ tags=["assessment"], ) +@router.get("/getDoenetState") +async def getdoenetstate(request: Request, div_id: str, + course_name: str, event: str, + user=Depends(auth_manager) +): + request_data = AssessmentRequest(course=course_name, div_id=div_id, event=event) + assessment_results = await get_assessment_results_internal(request_data, request, user) + + if assessment_results is not None: + doenet_state = assessment_results["answer"]["state"] + doenet_state["success"] = True + doenet_state["loadedState"] = True + return JSONResponse( status_code=200, content=jsonable_encoder(doenet_state) ) + else: + return JSONResponse( + status_code=200, content=jsonable_encoder({"loadedState": False, "success": True}) + ) # getAssessResults # ---------------- @@ -75,6 +94,18 @@ async def get_assessment_results( request_data: AssessmentRequest, request: Request, user=Depends(auth_manager), +): + assessment_results = await get_assessment_results_internal(request_data, request, user) + if assessment_results is not None: + return make_json_response(detail=assessment_results) + else: + return make_json_response(detail="no data") + + +async def get_assessment_results_internal( + request_data: AssessmentRequest, + request: Request, + user=Depends(auth_manager), ): # if the user is not logged in an HTTP 401 will be returned. # Otherwise if the user is an instructor then use the provided @@ -95,7 +126,7 @@ async def get_assessment_results( 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 make_json_response(detail="no data") + return None ret = row.dict() rslogger.debug(f"row is {ret}") if "timestamp" in ret: @@ -118,7 +149,7 @@ async def get_assessment_results( ret["comment"] = grades.comment ret["score"] = grades.score rslogger.debug(f"Returning {ret}") - return make_json_response(detail=ret) + return ret # Define a simple model for the gethist request. diff --git a/bases/rsptx/book_server_api/routers/rslogging.py b/bases/rsptx/book_server_api/routers/rslogging.py index eae44d926..f9caabe63 100644 --- a/bases/rsptx/book_server_api/routers/rslogging.py +++ b/bases/rsptx/book_server_api/routers/rslogging.py @@ -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: diff --git a/bases/rsptx/interactives/runestone/__init__.py b/bases/rsptx/interactives/runestone/__init__.py index 0b544cac6..27b6afb64 100644 --- a/bases/rsptx/interactives/runestone/__init__.py +++ b/bases/rsptx/interactives/runestone/__init__.py @@ -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 @@ -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() # ----------------------- @@ -251,6 +253,7 @@ def build(options): "datafile": DataFile, "disqus": DisqusDirective, "dragndrop": DragNDrop, + "doenet": DoenetDirective, "groupsub": GroupSubmission, "hparsons": HParsonsDirective, "parsonsprob": ParsonsProblem, diff --git a/bases/rsptx/interactives/runestone/doenet/__init__.py b/bases/rsptx/interactives/runestone/doenet/__init__.py new file mode 100755 index 000000000..14312333c --- /dev/null +++ b/bases/rsptx/interactives/runestone/doenet/__init__.py @@ -0,0 +1 @@ +from .doenet import * diff --git a/bases/rsptx/interactives/runestone/doenet/doenet.py b/bases/rsptx/interactives/runestone/doenet/doenet.py new file mode 100644 index 000000000..b9c5909a3 --- /dev/null +++ b/bases/rsptx/interactives/runestone/doenet/doenet.py @@ -0,0 +1,104 @@ + +# ********* +# |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 . +# +__author__ = "jaltekruse" + +from docutils import nodes +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_doenet_html, depart_doenet_html)) + + +TEMPLATE_START = """ +
+
+ +
+
+""" + + +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_doenet_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_doenet_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): + """ + + 1+3000=4 + """ + + required_arguments = 1 + optional_arguments = 1 + has_content = True + option_spec = RunestoneIdDirective.option_spec.copy() + + def run(self): + super(DoenetDirective, self).run() + addQuestionToDB(self) + + doenet_node = DoenetNode() + doenet_node["runestone_options"] = self.options + self.add_name(doenet_node) # make this divid available as a target for :ref: + + maybeAddToAssignment(self) + + return [doenet_node] diff --git a/bases/rsptx/interactives/runestone/doenet/js/doenet.js b/bases/rsptx/interactives/runestone/doenet/js/doenet.js new file mode 100644 index 000000000..5d981dbae --- /dev/null +++ b/bases/rsptx/interactives/runestone/doenet/js/doenet.js @@ -0,0 +1,141 @@ +"use strict"; + +import RunestoneBase from "../../common/js/runestonebase.js"; +// TODO fix this, in the meantime including from sphinx_static_files.html +// ERROR in ./runestone/doenet/js/doenet-standalone.js 240673:19 +//Module parse failed: Unterminated template (240673:19) +//You may need an appropriate loader to handle this file type, currently no loaders are configured to process this file. See https://webpack.js.org/concepts#loaders +//import "./doenet-standalone.js"; + +console.log("IN DOENET - add event listener for splice"); +// SPLICE Events +window.addEventListener("message", (event) => { + console.log("IN DOENET - got a message", event); + if (event.data.subject == "SPLICE.reportScoreAndState") { + console.log(event.data.score); + console.log(event.data.state); + event.data.course_name = eBookConfig.course; + event.data.div_id = event.data.state.activityId; + let ev = { + event: "doenet", + div_id: event.data.state.activityId, + percent: event.data.score, + correct: event.data.score == 1 ? 'T' : 'F', + act: JSON.stringify(event.data), + answer: JSON.stringify(event.data), + }; + window.componentMap[event.data.state.activityId].logBookEvent(ev); + } else if (event.data.subject == "SPLICE.sendEvent") { + console.log(event.data.location); + console.log(event.data.name); + console.log(event.data.data); + } +}); + +// separate into constructor and init +export class Doenet extends RunestoneBase { + constructor(opts) { + super(opts); + console.log(opts); + console.log("Jason update oct 24th"); + this.doenetML = opts.doenetML; + console.log("opts.orig.id", opts.orig.id); + var orig = $(opts.orig).find("div")[0]; + console.log(orig); + console.log(orig.id); + console.log(`${eBookConfig.new_server_prefix}/logger/bookevent`); + // todo - think about how we pass around the doenetML + //window.renderDoenetToContainer(orig, this.doenetML); + + var loadPageStateUrl = `/ns/assessment/getDoenetState?div_id=${opts.orig.id}&course_name=${eBookConfig.course}&event=doenet` + // data.div_id = this.divid; + // data.course = eBookConfig.course; + // data.event = eventInfo; + + window.renderDoenetToContainer(orig, this.doenetML, { + flags: { + // showCorrectness, + // readOnly, + // showFeedback, + // showHints, + showCorrectness: true, + readOnly: false, + solutionDisplayMode: "button", + showFeedback: true, + showHints: true, + allowLoadState: false, + allowSaveState: true, + allowLocalState: false, + allowSaveSubmissions: true, + allowSaveEvents: false, + autoSubmit: false, + }, + addBottomPadding: false, + activityId: opts.orig.id, + apiURLs: { + postMessages: true, + loadPageState: loadPageStateUrl + }, + }); + + //this.checkServer("hparsonsAnswer", true); + } + + async logCurrentAnswer(sid) {} + + renderFeedback() {} + + disableInteraction() {} + + checkLocalStorage() {} + setLocalStorage() {} + + restoreAnswers(data) { + console.log("TODO IMPLEMENT loading data from doenet activity", data); + } +} + +// +// Page Initialization +// + +$(document).on("runestone:login-complete", function () { + //ACFactory.createScratchActivecode(); + $("[data-component=doenet]").each(function () { + if ($(this).closest("[data-component=timedAssessment]").length == 0) { + // If this element exists within a timed component, don't render it here + try { + window.componentMap[this.id] = new Doenet({orig : this}); + // ACFactory.createActiveCode( + // this, + // $(this).find("textarea").data("lang") + // ); + } catch (err) { + console.log(`Error rendering Activecode Problem ${this.id} + Details: ${err}`); + } + } + }); + // The componentMap can have any component, not all of them have a disableSaveLoad + // method or an enableSaveLoad method. So we need to check for that before calling it. + // if (loggedout) { + // for (let k in window.componentMap) { + // if (window.componentMap[k].disableSaveLoad) { + // window.componentMap[k].disableSaveLoad(); + // } + // } + // } else { + // for (let k in window.componentMap) { + // if (window.componentMap[k].enableSaveLoad) { + // window.componentMap[k].enableSaveLoad(); + // } + // } + // } +}); + +if (typeof window.component_factory === "undefined") { + window.component_factory = {}; +} +window.component_factory.doenet = (opts) => { + return new Doenet(opts); +}; diff --git a/bases/rsptx/interactives/webpack.index.js b/bases/rsptx/interactives/webpack.index.js index dacde2cb1..42b701044 100644 --- a/bases/rsptx/interactives/webpack.index.js +++ b/bases/rsptx/interactives/webpack.index.js @@ -64,6 +64,7 @@ const module_map = { import("./runestone/clickableArea/js/timedclickable.js"), codelens: () => import("./runestone/codelens/js/codelens.js"), datafile: () => import("./runestone/datafile/js/datafile.js"), + doenet: () => import("./runestone/doenet/js/doenet.js"), dragndrop: () => import("./runestone/dragndrop/js/timeddnd.js"), fillintheblank: () => import("./runestone/fitb/js/timedfitb.js"), groupsub: () => import("./runestone/groupsub/js/groupsub.js"), @@ -87,6 +88,7 @@ const module_map = { // TODO: since this isn't in a ``data-component``, need to trigger an import of this code manually. webwork: () => import("./runestone/webwork/js/webwork.js"), youtube: () => import("./runestone/video/js/runestonevideo.js"), + doenet: () => import("./runestone/doenet/js/doenet.js"), }; const module_map_cache = {}; diff --git a/bases/rsptx/web2py_server/applications/runestone/controllers/admin.py b/bases/rsptx/web2py_server/applications/runestone/controllers/admin.py index 3a25fb328..b51f96847 100644 --- a/bases/rsptx/web2py_server/applications/runestone/controllers/admin.py +++ b/bases/rsptx/web2py_server/applications/runestone/controllers/admin.py @@ -44,6 +44,7 @@ codelens=ALL_AUTOGRADE_OPTIONS, datafile=[], dragndrop=["manual", "all_or_nothing", "pct_correct", "interact"], + doenet=ALL_AUTOGRADE_OPTIONS, external=[], fillintheblank=ALL_AUTOGRADE_OPTIONS, khanex=ALL_AUTOGRADE_OPTIONS, @@ -85,6 +86,7 @@ clickablearea=ALL_WHICH_OPTIONS, codelens=ALL_WHICH_OPTIONS, datafile=[], + doenet=ALL_WHICH_OPTIONS, dragndrop=ALL_WHICH_OPTIONS, external=[], fillintheblank=ALL_WHICH_OPTIONS, diff --git a/bases/rsptx/web2py_server/applications/runestone/models/db_ebook.py b/bases/rsptx/web2py_server/applications/runestone/models/db_ebook.py index 528f9a2c2..87337b56e 100644 --- a/bases/rsptx/web2py_server/applications/runestone/models/db_ebook.py +++ b/bases/rsptx/web2py_server/applications/runestone/models/db_ebook.py @@ -262,6 +262,18 @@ migrate=bookserver_owned("microparsons_answers"), ) +db.define_table( + "doenet_answers", + Field("timestamp", "datetime"), + Field("div_id", "string"), + Field("sid", "string"), + Field("course_name", "string"), + Field("answer", "json"), + Field("correct", "boolean"), + Field("percent", "double"), + migrate=bookserver_owned("doenet_answers"), +) + # payments # -------- diff --git a/bases/rsptx/web2py_server/applications/runestone/modules/questions_report.py b/bases/rsptx/web2py_server/applications/runestone/modules/questions_report.py index a8eabaa92..e738e170d 100644 --- a/bases/rsptx/web2py_server/applications/runestone/modules/questions_report.py +++ b/bases/rsptx/web2py_server/applications/runestone/modules/questions_report.py @@ -179,6 +179,12 @@ def questions_to_grades( & (db.useinfo.div_id == db.mchoice_answers.div_id) & (db.mchoice_answers.course_name == course_name) ), + db.doenet_answers.on( + (db.useinfo.timestamp == db.doenet_answers.timestamp) + & (db.useinfo.sid == db.doenet_answers.sid) + & (db.useinfo.div_id == db.doenet_answers.div_id) + & (db.doenet_answers.course_name == course_name) + ), db.parsons_answers.on( (db.useinfo.timestamp == db.parsons_answers.timestamp) & (db.useinfo.sid == db.parsons_answers.sid) @@ -399,6 +405,12 @@ def ts_get(table): row.parsons_answers.correct, ts_get(row.parsons_answers), ) + elif question_type == "doenet": + return ( + row.doenet_answers.answer, + row.doenet_answers.correct, + ts_get(row.doenet_answers), + ) elif question_type == "shortanswer": # Prefer data from the shortanswer table if we have it; otherwise, we can use useinfo's act. answer, ts = ( @@ -517,6 +529,9 @@ def query_assignment( db.parsons_answers.answer, db.parsons_answers.correct, db.parsons_answers.timestamp, + db.doenet_answers.answer, + db.doenet_answers.correct, + db.doenet_answers.timestamp, ##db.shortanswer_answers.answer, ##db.shortanswer_answers.timestamp, db.useinfo.timestamp, @@ -552,6 +567,10 @@ def query_assignment( (db.questions.question_type == "mchoice") & (db.question_grades.answer_id == db.mchoice_answers.id) ), + db.doenet_answers.on( + (db.questions.question_type == "doenet") + & (db.question_grades.answer_id == db.doenet_answers.id) + ), db.parsons_answers.on( (db.questions.question_type == "parsonsprob") & (db.question_grades.answer_id == db.parsons_answers.id) diff --git a/bases/rsptx/web2py_server/applications/runestone/modules/rs_grading.py b/bases/rsptx/web2py_server/applications/runestone/modules/rs_grading.py index 6428d77e1..8c9035e3e 100644 --- a/bases/rsptx/web2py_server/applications/runestone/modules/rs_grading.py +++ b/bases/rsptx/web2py_server/applications/runestone/modules/rs_grading.py @@ -152,6 +152,17 @@ def _score_one_microparsons(row, points, autograde): pct_correct = 0 return _score_from_pct_correct(pct_correct, points, autograde) +def _score_one_doenet(row, points, autograde): + # row is from doenet_answers + if autograde == "pct_correct" and "percent" in row and row.percent is not None: + pct_correct = int(round(row.percent * 100)) + else: + if row.correct: + pct_correct = 100 + else: + pct_correct = 0 + return _score_from_pct_correct(pct_correct, points, autograde) + def _score_one_fitb(row, points, autograde): # row is from fitb_answers @@ -401,6 +412,29 @@ def _scorable_microparsons_answers( query = query & (db.microparsons_answers.timestamp <= now) return db(query).select(orderby=db.microparsons_answers.timestamp) +def _scorable_doenet_answers( + course_name, + sid, + question_name, + points, + deadline, + practice_start_time=None, + db=None, + now=None, +): + query = ( + (db.doenet_answers.course_name == course_name) + & (db.doenet_answers.sid == sid) + & (db.doenet_answers.div_id == question_name) + ) + if deadline: + query = query & (db.doenet_answers.timestamp < deadline) + if practice_start_time: + query = query & (db.doenet_answers.timestamp >= practice_start_time) + if now: + query = query & (db.doenet_answers.timestamp <= now) + ret = db(query).select(orderby=db.doenet_answers.timestamp) + return ret def _scorable_fitb_answers( course_name, @@ -781,6 +815,21 @@ def _autograde_one_q( scoring_fn = _score_one_webwork logger.debug("AGDB - done with webwork") + elif question_type == "doenet": + logger.debug("grading a doenet!!") + results = _scorable_doenet_answers( + course_name, + sid, + question_name, + points, + deadline, + practice_start_time, + db=db, + now=now, + ) + scoring_fn = _score_one_doenet + logger.debug("AGDB - done with doenet") + elif question_type == "hparsons": logger.debug("grading a microparsons!!") results = _scorable_microparsons_answers( diff --git a/bases/rsptx/web2py_server/applications/runestone/static/js/admin.js b/bases/rsptx/web2py_server/applications/runestone/static/js/admin.js index a33fd26b3..b63ee26d4 100644 --- a/bases/rsptx/web2py_server/applications/runestone/static/js/admin.js +++ b/bases/rsptx/web2py_server/applications/runestone/static/js/admin.js @@ -1752,6 +1752,16 @@ function display_write() { var hiddenwrite = document.getElementById("hiddenwrite"); hiddenwrite.style.visibility = "visible"; + + var hiddenDoenetEditor = document.getElementById("doenet-question-editor"); + var hiddenRunestoneEditor = document.getElementById("runestone-question-editor"); + if (questiontype === "doenet") { + hiddenDoenetEditor.style.display = "block"; + hiddenRunestoneEditor.style.display = "none"; + } else { + hiddenRunestoneEditor.style.display = "block"; + hiddenDoenetEditor.style.display = "none"; + } } function find_name(lines) { @@ -1768,7 +1778,7 @@ function find_name(lines) { } // Called when the "Done" button of the "Write" dialog is clicked. -function create_question(formdata) { +async function create_question(formdata) { if (formdata.qchapter.value == "Chapter") { alert("Please select a chapter for this question"); return; @@ -1776,6 +1786,29 @@ function create_question(formdata) { if (formdata.createpoints.value == "") { formdata.createpoints.value = "1"; } + var question; + if (formdata.template.value == "doenet") { + question = (await returnAllStateVariables1())['/_codeeditor1'].stateValues.text; + var questionId = "doenet-" + Math.floor(Math.random() * 10000000); + question = "" + question; + + formdata.qrawhtml.value = + `
+
+ +
+
` + } else { + question = formdata.qcode.value; + } + + var lines = question.split("\n"); + var name = find_name(lines); + + if (formdata.template.value == "doenet") { + } if (!formdata.qrawhtml.value) { alert("No HTML for this question, please generate it."); return; @@ -1786,11 +1819,7 @@ function create_question(formdata) { var assignmentid = select.options[select.selectedIndex].value; var assignmentname = select.options[select.selectedIndex].text; var template = formdata.template.value; - var qcode = formdata.qcode.value; - var lines = qcode.split("\n"); var htmlsrc = formdata.qrawhtml.value; - var name = find_name(lines); - var question = formdata.qcode.value; var difficulty = formdata.difficulty; for (var i = 0; i < difficulty.length; i++) { if (difficulty[i].checked == true) { @@ -1985,6 +2014,10 @@ async function renderRunestoneComponent(componentSrc, whereDiv, moreOpts) { ) { componentKind = "webwork"; } + + if (componentSrc.indexOf("doenet") >= 0) { + componentKind = "doenet"; + } // Import all the js needed for this component before rendering await runestoneComponents.runestone_import(componentKind); let opt = {}; @@ -2004,12 +2037,15 @@ async function renderRunestoneComponent(componentSrc, whereDiv, moreOpts) { } } - if (typeof component_factory === "undefined") { + if (typeof component_factory === "undefined" && componentKind != "doenet") { alert( "Error: Missing the component factory! probably a webpack version mismatch" ); } else { - if (!component_factory[componentKind] && !jQuery(`#${whereDiv}`).html()) { + if (false && componentKind == "doenet") { + console.log("wonder if I need to do something here"); + + } else if (!component_factory[componentKind] && !jQuery(`#${whereDiv}`).html()) { jQuery(`#${whereDiv}`).html( `

Preview not available for ${componentKind}

` ); @@ -2017,7 +2053,8 @@ async function renderRunestoneComponent(componentSrc, whereDiv, moreOpts) { try { let res = component_factory[componentKind](opt); res.multiGrader = moreOpts.multiGrader; - if (componentKind === "activecode") { + if (componentKind === "activecode" + || componentKind == "doenet") { if (moreOpts.multiGrader) { window.componentMap[ `${moreOpts.gradingContainer} ${res.divid}` @@ -2101,7 +2138,13 @@ async function renderRunestoneComponent(componentSrc, whereDiv, moreOpts) { } // $(`#${whereDiv}`).css("background-color", "white"); } - MathJax.typeset([document.querySelector(`#${whereDiv}`)]); + + + if (componentKind == "doenet") { + //window.renderDoenetToContainer(document.querySelector(".doenetml-applet")); + } else { + MathJax.typeset([document.querySelector(`#${whereDiv}`)]); + } } // Called by the "Search" button in the "Search question bank" panel. diff --git a/bases/rsptx/web2py_server/applications/runestone/views/_sphinx_static_files.html b/bases/rsptx/web2py_server/applications/runestone/views/_sphinx_static_files.html index 11fbe101c..7337188ec 100644 --- a/bases/rsptx/web2py_server/applications/runestone/views/_sphinx_static_files.html +++ b/bases/rsptx/web2py_server/applications/runestone/views/_sphinx_static_files.html @@ -10,6 +10,9 @@ + + + {{ if 'ptx_js_version' in globals() and 'webwork_js_version' in globals(): }} diff --git a/bases/rsptx/web2py_server/applications/runestone/views/admin/assignments.html b/bases/rsptx/web2py_server/applications/runestone/views/admin/assignments.html index 84d282eff..54fd9fbca 100644 --- a/bases/rsptx/web2py_server/applications/runestone/views/admin/assignments.html +++ b/bases/rsptx/web2py_server/applications/runestone/views/admin/assignments.html @@ -313,6 +313,9 @@
Search Question Bank
+ + + +