From 18392764b3c3933a7e0448aa8a01f129c177880c Mon Sep 17 00:00:00 2001 From: Bradley Miller Date: Fri, 12 Jun 2026 13:22:28 -0500 Subject: [PATCH 1/4] Add: new books for StudyClues --- bases/rsptx/assignment_server_api/routers/student.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/bases/rsptx/assignment_server_api/routers/student.py b/bases/rsptx/assignment_server_api/routers/student.py index 0f0ff55d0..f7ee4475e 100644 --- a/bases/rsptx/assignment_server_api/routers/student.py +++ b/bases/rsptx/assignment_server_api/routers/student.py @@ -192,9 +192,12 @@ def get_studyclues_book_id(course: CoursesValidator) -> str: "csawesome2": 28, "httlacs": 29, "py4e-int": 30, - "PTXSB": 28, "cppds2": 35, "thinkcspy": 36, + "dmoi-4": 41, + "ac-single": 42, + "py4eint": 43, + "foppff": 44, # Add more mappings as needed } return course_to_book_id.get(course.base_course, 28) From f1c2e7dbf9e452700614471289994f2fa1fe1b82 Mon Sep 17 00:00:00 2001 From: Bradley Miller Date: Fri, 12 Jun 2026 13:50:04 -0500 Subject: [PATCH 2/4] Remove unused controllers and endpoints --- .../runestone/controllers/ajax.py | 1730 +---------------- .../runestone/controllers/books.py | 427 ---- .../runestone/controllers/designer.py | 176 -- .../runestone/controllers/exams.py | 112 -- .../runestone/controllers/proxy.py | 122 -- .../runestone/controllers/toctree.rst | 9 - 6 files changed, 13 insertions(+), 2563 deletions(-) delete mode 100644 bases/rsptx/web2py_server/applications/runestone/controllers/books.py delete mode 100644 bases/rsptx/web2py_server/applications/runestone/controllers/designer.py delete mode 100644 bases/rsptx/web2py_server/applications/runestone/controllers/exams.py delete mode 100644 bases/rsptx/web2py_server/applications/runestone/controllers/proxy.py delete mode 100644 bases/rsptx/web2py_server/applications/runestone/controllers/toctree.rst diff --git a/bases/rsptx/web2py_server/applications/runestone/controllers/ajax.py b/bases/rsptx/web2py_server/applications/runestone/controllers/ajax.py index 8d6bd8f1b..1521be3ce 100644 --- a/bases/rsptx/web2py_server/applications/runestone/controllers/ajax.py +++ b/bases/rsptx/web2py_server/applications/runestone/controllers/ajax.py @@ -1,90 +1,30 @@ # ************************* # |docname| - Runestone API # ************************* -# **Most of this file is Deprecated** -# **Most of the endpoints in this file are no longer used. See BookServer ** -# **Do not** make any changes to the following functions. They will be removed in an upcoming release. -# This module implements the API that the Runestone Components use to communicate with a Runestone Server. -# If you are trying to debug some browser to server API stuff you almost certainly -# want to check the BookServer not here. -# def compareAndUpdateCookieData(sid: str): -# def hsblog(): -# def runlog(): -# def gethist(): -# def getuser(): -# def set_tz_offset(): -# def updatelastpage(): -# def getCompletionStatus(): -# def getAllCompletionStatus(): -# def getlastpage(): -# def _getCorrectStats(miscdata, event): -# def _getStudentResults(question: str): -# def getaggregateresults(): -# def getpollresults(): -# def gettop10Answers(): -# def getassignmentgrade(): -# def _canonicalize_tz(tstring): -# def getAssessResults(): -# def tookTimedAssessment(): -# def get_datafile(): -# def _same_class(user1: str, user2: str) -> bool: -# def login_status(): -# def get_question_source(): +# **Most of this file is Deprecated.** +# The endpoints that used to live here have moved to the BookServer. Only the +# endpoints still required by the legacy web2py application remain: +# +# * ``set_tz_offset()`` -- records the browser timezone offset in the session +# (still posted to by some web2py views). +# * ``getassignmentgrade()`` -- returns a student's grade/comment for a question. +# * ``broadcast_code()`` -- lets an instructor share scratch ActiveCode with the +# whole class. +# +# If you are debugging browser-to-server API behavior you almost certainly want +# the BookServer, not this file. # -# TODO: Move these to a new controller file (maybe admin.py) -# def preview_question(): -# def save_donate(): -# def did_donate(): -# def broadcast_code(): -# def update_selected_question(): -# # # Imports # ======= -# These are listed in the order prescribed by `PEP 8 -# `_. -from collections import Counter import datetime -from io import open import json import logging -from lxml import html -import math -import os -import random -import re -import subprocess -from textwrap import dedent -import uuid - -# Third-party imports -# ------------------- -from bleach import clean -from dateutil.parser import parse - -# Local application imports -# ------------------------- -from feedback import is_server_feedback, fitb_feedback -from rs_practice import _get_qualified_questions logger = logging.getLogger(settings.logger) logger.setLevel(settings.log_level) logger.propagate = False -EVENT_TABLE = { - "mChoice": "mchoice_answers", - "fillb": "fitb_answers", - "dragNdrop": "dragndrop_answers", - "clickableArea": "clickablearea_answers", - "parsons": "parsons_answers", - "codelensq": "codelens_answers", - "shortanswer": "shortanswer_answers", - "fillintheblank": "fitb_answers", - "mchoice": "mchoice_answers", - "dragndrop": "dragndrop_answers", - "clickablearea": "clickablearea_answers", - "parsonsprob": "parsons_answers", -} - +# Comment prefix by language, used when sharing instructor code. COMMENT_MAP = { "sql": "--", "python": "#", @@ -95,986 +35,12 @@ } -def compareAndUpdateCookieData(sid: str): - if ( - "ipuser" in request.cookies - and request.cookies["ipuser"].value != sid - and request.cookies["ipuser"].value.endswith("@" + request.client) - ): - db.useinfo.update_or_insert( - db.useinfo.sid == request.cookies["ipuser"].value, sid=sid - ) - - -# Endpoints -# ========= -# -# .. _hsblog endpoint: -# -# hsblog endpoint -# --------------- -# Given a JSON record of a clickstream event record the event in the ``useinfo`` table. -# If the event is an answer to a runestone question record that answer in the database in -# one of the xxx_answers tables. -# -def hsblog(): - setCookie = False - if auth.user: - if request.vars.course != auth.user.course_name: - return json.dumps( - dict( - log=False, - message="You appear to have changed courses in another tab. Please switch to this course", - ) - ) - sid = auth.user.username - compareAndUpdateCookieData(sid) - setCookie = True # we set our own cookie anyway to eliminate many of the extraneous anonymous - # log entries that come from auth timing out even but the user hasn't reloaded - # the page. - # If the incoming data contains an sid then prefer that. - if request.vars.sid: - sid = request.vars.sid - else: - if request.vars.clientLoginStatus == "true": - logger.error("Session Expired") - return json.dumps(dict(log=False, message="Session Expired")) - - if "ipuser" in request.cookies: - sid = request.cookies["ipuser"].value - setCookie = True - else: - sid = str(uuid.uuid1().int) + "@" + request.client - setCookie = True - act = request.vars.get("act", "") - div_id = request.vars.div_id - event = request.vars.event - course = request.vars.course - # Get the current time, rounded to the nearest second -- this is how time time will be stored in the database. - ts = datetime.datetime.utcnow() - ts -= datetime.timedelta(microseconds=ts.microsecond) - tt = request.vars.time - if not tt: - tt = 0 - - try: - db.useinfo.insert( - sid=sid, - act=act[0:512], - div_id=div_id, - event=event, - timestamp=ts, - course_id=course, - ) - except Exception as e: - logger.error( - "failed to insert log record for {} in {} : {} {} {}".format( - sid, course, div_id, event, act - ) - ) - logger.error("Details: {}".format(e)) - - if event == "timedExam" and (act == "finish" or act == "reset" or act == "start"): - logger.debug(act) - if act == "reset": - r = "T" - else: - r = None - - try: - db.timed_exam.insert( - sid=sid, - course_name=course, - correct=int(request.vars.correct or 0), - incorrect=int(request.vars.incorrect or 0), - skipped=int(request.vars.skipped or 0), - time_taken=int(tt), - timestamp=ts, - div_id=div_id, - reset=r, - ) - except Exception as e: - logger.debug( - "failed to insert a timed exam record for {} in {} : {}".format( - sid, course, div_id - ) - ) - logger.debug( - "correct {} incorrect {} skipped {} time {}".format( - request.vars.correct, - request.vars.incorrect, - request.vars.skipped, - request.vars.time, - ) - ) - logger.debug("Error: {}".format(e.message)) - - # Produce a default result. - res = dict(log=True, timestamp=str(ts)) - try: - pct = float(request.vars.percent) - except ValueError: - pct = None - except TypeError: - pct = None - - # Process this event. - if event == "mChoice" and auth.user: - answer = request.vars.answer or "" - correct = request.vars.correct or False - db.mchoice_answers.insert( - sid=sid, - timestamp=ts, - div_id=div_id, - answer=answer, - correct=correct, - course_name=course, - percent=pct, - ) - elif event == "fillb" and auth.user: - answer_json = request.vars.answer - correct = request.vars.correct or False - # Grade on the server if needed. - do_server_feedback, feedback = is_server_feedback(div_id, course) - if do_server_feedback and answer_json is not None: - correct, res_update = fitb_feedback(answer_json, feedback) - res.update(res_update) - pct = res["percent"] - - # Save this data. - db.fitb_answers.insert( - sid=sid, - timestamp=ts, - div_id=div_id, - answer=answer_json, - correct=correct, - course_name=course, - percent=pct, - ) - - elif event == "dragNdrop" and auth.user: - answers = request.vars.answer - minHeight = request.vars.minHeight - correct = request.vars.correct or False - - db.dragndrop_answers.insert( - sid=sid, - timestamp=ts, - div_id=div_id, - answer=answers, - correct=correct, - course_name=course, - min_height=minHeight, - percent=pct, - ) - elif event == "clickableArea" and auth.user: - correct = request.vars.correct or False - db.clickablearea_answers.insert( - sid=sid, - timestamp=ts, - div_id=div_id, - answer=act, - correct=correct, - course_name=course, - percent=pct, - ) - - elif event == "parsons" and auth.user: - correct = request.vars.correct or False - answer = request.vars.answer or "" - source = request.vars.source - db.parsons_answers.insert( - sid=sid, - timestamp=ts, - div_id=div_id, - answer=answer, - source=source, - correct=correct, - course_name=course, - percent=pct, - ) - - elif event == "codelensq" and auth.user: - correct = request.vars.correct or False - answer = request.vars.answer or "" - source = request.vars.source - db.codelens_answers.insert( - sid=sid, - timestamp=ts, - div_id=div_id, - answer=answer, - source=source, - correct=correct, - course_name=course, - percent=pct, - ) - - elif event == "shortanswer" and auth.user: - db.shortanswer_answers.insert( - sid=sid, - answer=act, - div_id=div_id, - timestamp=ts, - course_name=course, - ) - - elif event == "unittest" and auth.user: - statslist = act.split(":") - if "undefined" not in act: - pct = float(statslist[1]) - passed = int(statslist[3]) - failed = int(statslist[5]) - if math.isnan(pct): - pct = 0 - else: - pct = passed = failed = 0 - logger.error(f"Got undefined unittest results for {div_id} {sid}") - if pct >= 99.99999: - correct = "T" - else: - correct = "F" - db.unittest_answers.insert( - sid=sid, - timestamp=ts, - div_id=div_id, - correct=correct, - passed=passed, - failed=failed, - course_name=course, - percent=pct, - ) - - response.headers["content-type"] = "application/json" - if setCookie: - response.cookies["ipuser"] = sid - response.cookies["ipuser"]["expires"] = 24 * 3600 * 90 - response.cookies["ipuser"]["path"] = "/" - if auth.user: - response.cookies["last_course"] = auth.user.course_name - response.cookies["last_course"]["expires"] = 24 * 3600 * 90 - response.cookies["last_course"]["path"] = "/" - - return json.dumps(res) - - -# .. _runlog endpoint: -# -# runlog endpoint -# --------------- -# The `logRunEvent` client-side function calls this endpoint to record TODO... -def runlog(): # Log errors and runs with code - # response.headers['content-type'] = 'application/json' - setCookie = False - if auth.user: - if request.vars.course != auth.user.course_name: - return json.dumps( - dict( - log=False, - message="You appear to have changed courses in another tab. Please switch to this course", - ) - ) - if request.vars.sid: - sid = request.vars.sid - else: - sid = auth.user.username - setCookie = True - - else: - if request.vars.clientLoginStatus == "true": - logger.error("Session Expired") - return json.dumps(dict(log=False, message="Session Expired")) - if "ipuser" in request.cookies: - sid = request.cookies["ipuser"].value - setCookie = True - else: - sid = str(uuid.uuid1().int) + "@" + request.client - setCookie = True - div_id = request.vars.div_id - course = request.vars.course - code = request.vars.code if request.vars.code else "" - ts = datetime.datetime.utcnow() - error_info = request.vars.errinfo - pre = request.vars.prefix if request.vars.prefix else "" - post = request.vars.suffix if request.vars.suffix else "" - if error_info != "success": - event = "ac_error" - act = str(error_info)[:512] - else: - act = "run" - if request.vars.event: - event = request.vars.event - else: - event = "activecode" - num_tries = 3 - done = False - while num_tries > 0 and not done: - try: - db.useinfo.insert( - sid=sid, - act=act, - div_id=div_id, - event=event, - timestamp=ts, - course_id=course, - ) - done = True - except Exception as e: - logger.error( - "probable Too Long problem trying to insert sid={} act={} div_id={} event={} timestamp={} course_id={} exception={}".format( - sid, act, div_id, event, ts, course, e - ) - ) - num_tries -= 1 - if num_tries == 0: - raise Exception("Runlog Failed to insert into useinfo") - - if auth.user: - if "to_save" in request.vars and ( - request.vars.to_save == "True" or request.vars.to_save == "true" - ): - num_tries = 3 - done = False - dbcourse = ( - db(db.courses.course_name == course).select(**SELECT_CACHE).first() - ) - while num_tries > 0 and not done: - try: - db.code.insert( - sid=sid, - acid=div_id, - code=code, - emessage=error_info, - timestamp=ts, - course_id=dbcourse, - language=request.vars.lang, - ) - if request.vars.partner: - if _same_class(sid, request.vars.partner): - comchar = COMMENT_MAP.get(request.vars.lang, "#") - newcode = ( - "{} This code was shared by {}\n\n".format(comchar, sid) - + code - ) - db.code.insert( - sid=request.vars.partner, - acid=div_id, - code=newcode, - emessage=error_info, - timestamp=ts, - course_id=dbcourse, - language=request.vars.lang, - ) - else: - res = { - "message": "You must be enrolled in the same class as your partner" - } - return json.dumps(res) - done = True - except Exception as e: - num_tries -= 1 - logger.error("INSERT into code FAILED retrying -- {}".format(e)) - if num_tries == 0: - raise Exception("Runlog Failed to insert into code") - - res = {"log": True} - if setCookie: - response.cookies["ipuser"] = sid - response.cookies["ipuser"]["expires"] = 24 * 3600 * 90 - response.cookies["ipuser"]["path"] = "/" - return json.dumps(res) - - -# Ajax Handlers for saving and restoring active code blocks - - -def gethist(): - """ - return the history of saved code by this user for a particular acid - :Parameters: - - `acid`: id of the active code block - - `user`: optional identifier for the owner of the code - :Return: - - json object containing a list/array of source texts - """ - codetbl = db.code - acid = request.vars.acid - - # if vars.sid then we know this is being called from the grading interface - if request.vars.sid: - sid = request.vars.sid - if auth.user and verifyInstructorStatus( - auth.user.course_name, auth.user.id - ): # noqa: F405 - course_id = auth.user.course_id - else: - course_id = None - elif auth.user: - sid = auth.user.username - course_id = auth.user.course_id - else: - sid = None - course_id = None - - res = {} - if sid: - query = ( - (codetbl.sid == sid) - & (codetbl.acid == acid) - & (codetbl.course_id == course_id) - & (codetbl.timestamp != None) # noqa: E711 - ) - res["acid"] = acid - res["sid"] = sid - # get the code they saved in chronological order; id order gets that for us - r = db(query).select(orderby=codetbl.id) - res["history"] = [row.code for row in r] - res["timestamps"] = [ - row.timestamp.replace(tzinfo=datetime.timezone.utc).isoformat() for row in r - ] - - response.headers["content-type"] = "application/json" - return json.dumps(res) - - -# @auth.requires_login() -# This function is deprecated as of June 2019 -# We need to keep it in place as long as we continue to serve books -# from runestone/static/ When that period is over we can eliminate -def getuser(): - response.headers["content-type"] = "application/json" - - if auth.user: - try: - # return the list of courses that auth.user is registered for to keep them from - # accidentally wandering into courses they are not registered for. - cres = db( - (db.user_courses.user_id == auth.user.id) - & (db.user_courses.course_id == db.courses.id) - ).select(db.courses.course_name) - clist = [] - for row in cres: - clist.append(row.course_name) - res = { - "email": auth.user.email, - "nick": auth.user.username, - "donated": auth.user.donated, - "isInstructor": verifyInstructorStatus( # noqa: F405 - auth.user.course_name, auth.user.id - ), - "course_list": clist, - } - session.timezoneoffset = request.vars.timezoneoffset - logger.debug( - "setting timezone offset in session %s hours" % session.timezoneoffset - ) - except Exception: - res = dict(redirect=auth.settings.login_url) # ?_next=.... - else: - res = dict(redirect=auth.settings.login_url) # ?_next=.... - if session.readings: - res["readings"] = session.readings - logger.debug("returning login info: %s" % res) - return json.dumps([res]) - - def set_tz_offset(): session.timezoneoffset = request.vars.timezoneoffset logger.debug("setting timezone offset in session %s hours" % session.timezoneoffset) return "done" -# -# Ajax Handlers to update and retrieve the last position of the user in the course -# -def updatelastpage(): - lastPageUrl = request.vars.lastPageUrl - lastPageScrollLocation = request.vars.lastPageScrollLocation - if lastPageUrl is None: - return # todo: log request.vars, request.args and request.env.path_info - course = request.vars.course - completionFlag = request.vars.completionFlag - lastPageChapter = lastPageUrl.split("/")[-2] - lastPageSubchapter = ".".join(lastPageUrl.split("/")[-1].split(".")[:-1]) - if auth.user: - done = False - num_tries = 3 - while not done and num_tries > 0: - try: - db( - (db.user_state.user_id == auth.user.id) - & (db.user_state.course_name == course) - ).update( - last_page_url=lastPageUrl, - last_page_chapter=lastPageChapter, - last_page_subchapter=lastPageSubchapter, - last_page_scroll_location=lastPageScrollLocation, - last_page_accessed_on=datetime.datetime.utcnow(), - ) - done = True - except Exception: - num_tries -= 1 - if num_tries == 0: - raise Exception("Failed to save the user state in update_last_page") - - done = False - num_tries = 3 - while not done and num_tries > 0: - try: - db( - (db.user_sub_chapter_progress.user_id == auth.user.id) - & (db.user_sub_chapter_progress.chapter_id == lastPageChapter) - & ( - db.user_sub_chapter_progress.sub_chapter_id - == lastPageSubchapter - ) - & ( - (db.user_sub_chapter_progress.course_name == course) - | ( - db.user_sub_chapter_progress.course_name == None - ) # Back fill for old entries without course - ) - ).update( - status=completionFlag, - end_date=datetime.datetime.utcnow(), - course_name=course, - ) - done = True - except Exception: - num_tries -= 1 - if num_tries == 0: - raise Exception("Failed to save sub chapter progress in update_last_page") - - practice_settings = db(db.course_practice.course_name == auth.user.course_name) - if ( - practice_settings.count() != 0 - and practice_settings.select().first().flashcard_creation_method == 0 - ): - # Since each authenticated user has only one active course, we retrieve the course this way. - course = ( - db(db.courses.id == auth.user.course_id).select(**SELECT_CACHE).first() - ) - - # We only retrieve questions to be used in flashcards if they are marked for practice purpose. - questions = _get_qualified_questions( - course.base_course, lastPageChapter, lastPageSubchapter, db - ) - if len(questions) > 0: - now = datetime.datetime.utcnow() - now_local = now - datetime.timedelta( - hours=( - float(session.timezoneoffset) - if "timezoneoffset" in session - else 0 - ) - ) - existing_flashcards = db( - (db.user_topic_practice.user_id == auth.user.id) - & (db.user_topic_practice.course_name == auth.user.course_name) - & (db.user_topic_practice.chapter_label == lastPageChapter) - & (db.user_topic_practice.sub_chapter_label == lastPageSubchapter) - & (db.user_topic_practice.question_name == questions[0].name) - ) - # There is at least one qualified question in this subchapter, so insert a flashcard for the subchapter. - if completionFlag == "1" and existing_flashcards.isempty(): - db.user_topic_practice.insert( - user_id=auth.user.id, - course_name=auth.user.course_name, - chapter_label=lastPageChapter, - sub_chapter_label=lastPageSubchapter, - question_name=questions[0].name, - # Treat it as if the first eligible question is the last one asked. - i_interval=0, - e_factor=2.5, - next_eligible_date=now_local.date(), - # add as if yesterday, so can practice right away - last_presented=now - datetime.timedelta(1), - last_completed=now - datetime.timedelta(1), - creation_time=now, - timezoneoffset=( - float(session.timezoneoffset) - if "timezoneoffset" in session - else 0 - ), - ) - if completionFlag == "0" and not existing_flashcards.isempty(): - existing_flashcards.delete() - - -def getCompletionStatus(): - if auth.user: - lastPageUrl = request.vars.lastPageUrl - lastPageChapter = lastPageUrl.split("/")[-2] - lastPageSubchapter = ".".join(lastPageUrl.split("/")[-1].split(".")[:-1]) - result = db( - (db.user_sub_chapter_progress.user_id == auth.user.id) - & (db.user_sub_chapter_progress.chapter_id == lastPageChapter) - & (db.user_sub_chapter_progress.sub_chapter_id == lastPageSubchapter) - & ( - (db.user_sub_chapter_progress.course_name == auth.user.course_name) - | ( - db.user_sub_chapter_progress.course_name == None - ) # for backward compatibility - ) - ).select(db.user_sub_chapter_progress.status) - rowarray_list = [] - if result: - for row in result: - res = {"completionStatus": row.status} - rowarray_list.append(res) - # question: since the javascript in user-highlights.js is going to look only at the first row, shouldn't - # we be returning just the *last* status? Or is there no history of status kept anyway? - return json.dumps(rowarray_list) - else: - # haven't seen this Chapter/Subchapter before - # make the insertions into the DB as necessary - - # we know the subchapter doesn't exist - db.user_sub_chapter_progress.insert( - user_id=auth.user.id, - chapter_id=lastPageChapter, - sub_chapter_id=lastPageSubchapter, - status=-1, - start_date=datetime.datetime.utcnow(), - course_name=auth.user.course_name, - ) - # the chapter might exist without the subchapter - result = db( - (db.user_chapter_progress.user_id == auth.user.id) - & (db.user_chapter_progress.chapter_id == lastPageChapter) - ).select() - if not result: - db.user_chapter_progress.insert( - user_id=auth.user.id, chapter_id=lastPageChapter, status=-1 - ) - return json.dumps([{"completionStatus": -1}]) - - -def getAllCompletionStatus(): - if auth.user: - result = db( - (db.user_sub_chapter_progress.user_id == auth.user.id) - & (db.user_sub_chapter_progress.course_name == auth.user.course_name) - ).select( - db.user_sub_chapter_progress.chapter_id, - db.user_sub_chapter_progress.sub_chapter_id, - db.user_sub_chapter_progress.status, - db.user_sub_chapter_progress.status, - db.user_sub_chapter_progress.end_date, - ) - rowarray_list = [] - if result: - for row in result: - if row.end_date is None: - endDate = 0 - else: - endDate = row.end_date.strftime("%d %b, %Y") - res = { - "chapterName": row.chapter_id, - "subChapterName": row.sub_chapter_id, - "completionStatus": row.status, - "endDate": endDate, - } - rowarray_list.append(res) - return json.dumps(rowarray_list) - - -@auth.requires_login() -def getlastpage(): - course = request.vars.course - course = db(db.courses.course_name == course).select(**SELECT_CACHE).first() - - result = db( - (db.user_state.user_id == auth.user.id) - & (db.user_state.course_name == course.course_name) - & (db.chapters.course_id == course.base_course) - & (db.user_state.last_page_chapter == db.chapters.chapter_label) - & (db.sub_chapters.chapter_id == db.chapters.id) - & (db.user_state.last_page_subchapter == db.sub_chapters.sub_chapter_label) - ).select( - db.user_state.last_page_url, - db.user_state.last_page_hash, - db.chapters.chapter_name, - db.user_state.last_page_scroll_location, - db.sub_chapters.sub_chapter_name, - ) - rowarray_list = [] - if result: - for row in result: - res = { - "lastPageUrl": row.user_state.last_page_url, - "lastPageHash": row.user_state.last_page_hash, - "lastPageChapter": row.chapters.chapter_name, - "lastPageSubchapter": row.sub_chapters.sub_chapter_name, - "lastPageScrollLocation": row.user_state.last_page_scroll_location, - } - rowarray_list.append(res) - return json.dumps(rowarray_list) - else: - db.user_state.insert(user_id=auth.user.id, course_name=course.course_name) - - -def _getCorrectStats(miscdata, event): - # TODO: update this to use the xxx_answer table - # select and count grouping by the correct column - # this version can suffer from division by zero error - sid = None - dbtable = EVENT_TABLE[event] # translate event to correct table - - if auth.user: - sid = auth.user.username - else: - if "ipuser" in request.cookies: - sid = request.cookies["ipuser"].value - - if sid: - course = ( - db(db.courses.course_name == miscdata["course"]) - .select(**SELECT_CACHE) - .first() - ) - tbl = db[dbtable] - - count_expr = tbl.correct.count() - rows = db((tbl.sid == sid) & (tbl.timestamp > course.term_start_date)).select( - tbl.correct, count_expr, groupby=tbl.correct - ) - total = 0 - correct = 0 - for row in rows: - count = row[count_expr] - total += count - if row[dbtable].correct: - correct = count - if total > 0: - pctcorr = round(float(correct) / total * 100) - else: - pctcorr = "unavailable" - else: - pctcorr = "unavailable" - - miscdata["yourpct"] = pctcorr - - -def _getStudentResults(question: str): - """ - Internal function to collect student answers - """ - cc = db(db.courses.id == auth.user.course_id).select().first() - qst = ( - db( - (db.questions.name == question) - & (db.questions.base_course == cc.base_course) - ) - .select() - .first() - ) - tbl_name = EVENT_TABLE[qst.question_type] - tbl = db[tbl_name] - - res = db( - (tbl.div_id == question) - & (tbl.course_name == cc.course_name) - & (tbl.timestamp >= cc.term_start_date) - ).select(tbl.sid, tbl.answer, orderby=tbl.sid) - - resultList = [] - if len(res) > 0: - currentSid = res[0].sid - currentAnswers = [] - - for row in res: - if row.answer: - answer = clean(row.answer) - else: - answer = None - - if row.sid == currentSid: - if answer is not None: - currentAnswers.append(answer) - else: - currentAnswers.sort() - resultList.append((currentSid, currentAnswers)) - currentAnswers = [answer] if answer is not None else [] - currentSid = row.sid - - currentAnswers.sort() - resultList.append((currentSid, currentAnswers)) - - return resultList - - -def getaggregateresults(): - course = request.vars.course - question = request.vars.div_id - # select act, count(*) from useinfo where div_id = 'question4_2_1' group by act; - response.headers["content-type"] = "application/json" - - if not auth.user: - return json.dumps([dict(answerDict={}, misc={}, emess="You must be logged in")]) - - is_instructor = verifyInstructorStatus(course, auth.user.id) # noqa: F405 - # Yes, these two things could be done as a join. but this **may** be better for performance - if course in ( - "thinkcspy", - "pythonds", - "fopp", - "csawesome", - "apcsareview", - "StudentCSP", - ): - start_date = datetime.datetime.utcnow() - datetime.timedelta(days=90) - else: - start_date = ( - db(db.courses.course_name == course) - .select(db.courses.term_start_date) - .first() - .term_start_date - ) - count = db.useinfo.id.count() - try: - result = db( - (db.useinfo.div_id == question) - & (db.useinfo.course_id == course) - & (db.useinfo.timestamp >= start_date) - ).select(db.useinfo.act, count, groupby=db.useinfo.act) - except Exception: - return json.dumps( - [dict(answerDict={}, misc={}, emess="Sorry, the request timed out")] - ) - - tdata = {} - tot = 0 - for row in result: - tdata[clean(row.useinfo.act)] = row[count] - tot += row[count] - - tot = float(tot) - rdata = {} - miscdata = {} - correct = "" - if tot > 0: - for key in tdata: - all_a = key.split(":") - try: - answer = all_a[1] - if "correct" in key: - correct = answer - count = int(tdata[key]) - if answer in rdata: - count += rdata[answer] / 100.0 * tot - pct = round(count / tot * 100.0) - - if answer != "undefined" and answer != "": - rdata[answer] = pct - except Exception as e: - logger.error("Bad data for %s data is %s -- %s" % (question, key, e)) - - miscdata["correct"] = correct - miscdata["course"] = course - - _getCorrectStats(miscdata, "mChoice") - - returnDict = dict(answerDict=rdata, misc=miscdata) - - if auth.user and is_instructor: - resultList = _getStudentResults(question) - returnDict["reslist"] = resultList - - return json.dumps([returnDict]) - - -def getpollresults(): - course = request.vars.course - div_id = request.vars.div_id - - response.headers["content-type"] = "application/json" - - query = """select act from useinfo - join (select sid, max(id) mid - from useinfo where event='poll' and div_id = %s and course_id = %s group by sid) as T - on id = T.mid""" - - rows = db.executesql(query, (div_id, course)) - - result_list = [] - for row in rows: - val = row[0].split(":")[0] - result_list.append(int(val)) - - # maps option : count - opt_counts = Counter(result_list) - - if result_list: - for i in range(max(result_list)): - if i not in opt_counts: - opt_counts[i] = 0 - # opt_list holds the option numbers from smallest to largest - # count_list[i] holds the count of responses that chose option i - opt_list = sorted(opt_counts.keys()) - count_list = [] - for i in opt_list: - count_list.append(opt_counts[i]) - - user_res = None - if auth.user: - user_res = ( - db( - (db.useinfo.sid == auth.user.username) - & (db.useinfo.course_id == course) - & (db.useinfo.div_id == div_id) - ) - .select(db.useinfo.act, orderby=~db.useinfo.id) - .first() - ) - - if user_res: - my_vote = user_res.act - else: - my_vote = -1 - - return json.dumps([len(result_list), opt_list, count_list, div_id, my_vote]) - - -def gettop10Answers(): - course = request.vars.course - question = request.vars.div_id - response.headers["content-type"] = "application/json" - rows = [] - - try: - dbcourse = db(db.courses.course_name == course).select(**SELECT_CACHE).first() - count_expr = db.fitb_answers.answer.count() - rows = db( - (db.fitb_answers.div_id == question) - & (db.fitb_answers.course_name == course) - & (db.fitb_answers.timestamp > dbcourse.term_start_date) - ).select( - db.fitb_answers.answer, - count_expr, - groupby=db.fitb_answers.answer, - orderby=~count_expr, - limitby=(0, 10), - ) - res = [ - {"answer": clean(row.fitb_answers.answer), "count": row[count_expr]} - for row in rows - ] - except Exception as e: - logger.debug(e) - res = "error in query" - - miscdata = {"course": course} - _getCorrectStats( - miscdata, "fillb" - ) # TODO: rewrite _getCorrectStats to use xxx_answers - - if auth.user and verifyInstructorStatus(course, auth.user.id): # noqa: F405 - resultList = _getStudentResults(question) - miscdata["reslist"] = resultList - - return json.dumps([res, miscdata]) - - def getassignmentgrade(): response.headers["content-type"] = "application/json" if not auth.user: @@ -1138,403 +104,6 @@ def getassignmentgrade(): return json.dumps([ret]) -def _canonicalize_tz(tstring): - x = re.search(r"\((.*)\)", tstring) - x = x.group(1) - y = x.split() - if len(y) == 1: - return tstring - else: - zstring = "".join([i[0] for i in y]) - return re.sub(r"(.*)\((.*)\)", r"\1({})".format(zstring), tstring) - - -# .. _getAssessResults: -# -# getAssessResults -# ---------------- -def getAssessResults(): - if not auth.user: - # can't query for user's answers if we don't know who the user is, so just load from local storage - return "" - - course = request.vars.course - div_id = request.vars.div_id - event = request.vars.event - if ( - verifyInstructorStatus(auth.user.course_name, auth.user) and request.vars.sid - ): # retrieving results for grader - sid = request.vars.sid - else: - sid = auth.user.username - - # TODO This whole thing is messy - get the deadline from the assignment in the db - if request.vars.deadline: - try: - deadline = parse(_canonicalize_tz(request.vars.deadline)) - tzoff = session.timezoneoffset if session.timezoneoffset else 0 - deadline = deadline + datetime.timedelta(hours=float(tzoff)) - deadline = deadline.replace(tzinfo=None) - except Exception: - logger.error("Bad Timezone - {}".format(request.vars.deadline)) - deadline = datetime.datetime.utcnow() - else: - deadline = datetime.datetime.utcnow() - - response.headers["content-type"] = "application/json" - - # Identify the correct event and query the database so we can load it from the server - if event == "fillb": - rows = ( - db( - (db.fitb_answers.div_id == div_id) - & (db.fitb_answers.course_name == course) - & (db.fitb_answers.sid == sid) - ) - .select( - db.fitb_answers.answer, - db.fitb_answers.timestamp, - orderby=~db.fitb_answers.id, - ) - .first() - ) - if not rows: - return "" # server doesn't have it so we load from local storage instead - # - res = {"answer": rows.answer, "timestamp": str(rows.timestamp)} - do_server_feedback, feedback = is_server_feedback(div_id, course) - if do_server_feedback and rows.answer != None: - correct, res_update = fitb_feedback(rows.answer, feedback) - res.update(res_update) - return json.dumps(res) - elif event == "mChoice": - rows = ( - db( - (db.mchoice_answers.div_id == div_id) - & (db.mchoice_answers.course_name == course) - & (db.mchoice_answers.sid == sid) - ) - .select( - db.mchoice_answers.answer, - db.mchoice_answers.timestamp, - db.mchoice_answers.correct, - orderby=~db.mchoice_answers.id, - ) - .first() - ) - if not rows: - return "" - res = { - "answer": rows.answer, - "timestamp": str(rows.timestamp), - "correct": rows.correct, - } - return json.dumps(res) - elif event == "dragNdrop": - rows = ( - db( - (db.dragndrop_answers.div_id == div_id) - & (db.dragndrop_answers.course_name == course) - & (db.dragndrop_answers.sid == sid) - ) - .select( - db.dragndrop_answers.answer, - db.dragndrop_answers.timestamp, - db.dragndrop_answers.correct, - db.dragndrop_answers.min_height, - orderby=~db.dragndrop_answers.id, - ) - .first() - ) - if not rows: - return "" - res = { - "answer": rows.answer, - "timestamp": str(rows.timestamp), - "correct": rows.correct, - "minHeight": str(rows.min_height), - } - return json.dumps(res) - elif event == "clickableArea": - rows = ( - db( - (db.clickablearea_answers.div_id == div_id) - & (db.clickablearea_answers.course_name == course) - & (db.clickablearea_answers.sid == sid) - ) - .select( - db.clickablearea_answers.answer, - db.clickablearea_answers.timestamp, - db.clickablearea_answers.correct, - orderby=~db.clickablearea_answers.id, - ) - .first() - ) - if not rows: - return "" - res = { - "answer": rows.answer, - "timestamp": str(rows.timestamp), - "correct": rows.correct, - } - return json.dumps(res) - elif event == "timedExam": - rows = ( - db( - (db.timed_exam.reset == None) # noqa: E711 - & (db.timed_exam.div_id == div_id) - & (db.timed_exam.course_name == course) - & (db.timed_exam.sid == sid) - ) - .select( - db.timed_exam.correct, - db.timed_exam.incorrect, - db.timed_exam.skipped, - db.timed_exam.time_taken, - db.timed_exam.timestamp, - db.timed_exam.reset, - orderby=~db.timed_exam.id, - ) - .first() - ) - if not rows: - return "" - res = { - "correct": rows.correct, - "incorrect": rows.incorrect, - "skipped": str(rows.skipped), - "timeTaken": str(rows.time_taken), - "timestamp": str(rows.timestamp), - "reset": str(rows.reset), - } - return json.dumps(res) - elif event == "parsons": - rows = ( - db( - (db.parsons_answers.div_id == div_id) - & (db.parsons_answers.course_name == course) - & (db.parsons_answers.sid == sid) - ) - .select( - db.parsons_answers.answer, - db.parsons_answers.source, - db.parsons_answers.timestamp, - orderby=~db.parsons_answers.id, - ) - .first() - ) - if not rows: - return "" - res = { - "answer": rows.answer, - "source": rows.source, - "timestamp": str(rows.timestamp), - } - return json.dumps(res) - elif event == "shortanswer": - logger.debug(f"Getting shortanswer: deadline is {deadline} ") - rows = db( - (db.shortanswer_answers.sid == sid) - & (db.shortanswer_answers.div_id == div_id) - & (db.shortanswer_answers.course_name == course) - ).select(orderby=~db.shortanswer_answers.id) - if not rows: - return "" - last_answer = None - if not request.vars.deadline: - row = rows[0] - else: - last_answer = rows[0] - for row in rows: - if row.timestamp <= deadline: - break - if row.timestamp > deadline: - row = None - - if row and row == last_answer: - res = {"answer": row.answer, "timestamp": row.timestamp.isoformat()} - else: - if row and row.timestamp <= deadline: - res = {"answer": row.answer, "timestamp": row.timestamp.isoformat()} - else: - res = { - "answer": "", - "timestamp": None, - "last_answer": last_answer.answer, - "last_timestamp": last_answer.timestamp.isoformat(), - } - srow = ( - db( - (db.question_grades.sid == sid) - & (db.question_grades.div_id == div_id) - & (db.question_grades.course_name == course) - ) - .select() - .first() - ) - if srow: - res["score"] = srow.score - res["comment"] = srow.comment - - return json.dumps(res) - - -def tookTimedAssessment(): - if auth.user: - sid = auth.user.username - else: - return json.dumps({"tookAssessment": False}) - - exam_id = request.vars.div_id - course = request.vars.course_name - rows = ( - db( - (db.timed_exam.div_id == exam_id) - & (db.timed_exam.sid == sid) - & (db.timed_exam.course_name == course) - ) - .select(orderby=~db.timed_exam.id) - .first() - ) - logger.debug(f"checking {exam_id} {sid} {course} {rows}") - if rows: - return json.dumps({"tookAssessment": True}) - else: - return json.dumps({"tookAssessment": False}) - - -# The request variable ``code`` must contain JSON-encoded RST to be rendered by Runestone. Only the HTML containing the actual Runestone component will be returned. -def preview_question(): - - begin = """ -.. raw:: html - - - -""" - end = """ - -.. raw:: html - - - -""" - - try: - code = begin + dedent(json.loads(request.vars.code)) + end - with open( - "applications/{}/build/preview/_sources/index.rst".format( - request.application - ), - "w", - encoding="utf-8", - ) as ixf: - ixf.write(code) - - # Note that ``os.environ`` isn't a dict, it's an object whose setter modifies environment variables. So, modifications of a copy/deepcopy still `modify the original environment `_. Therefore, convert it to a dict, where modifications will not affect the environment. - env = dict(os.environ) - # Prevent any changes to the database when building a preview question. - env.pop("DBURL", None) - # Run a runestone build. - # We would like to use sys.executable But when we run web2py - # in uwsgi then sys.executable is uwsgi which doesn't work. - # Why not just run runestone? - if "python" not in settings.python_interpreter: - logger.error(f"Error {settings.python_interpreter} is not a valid python") - return json.dumps( - f"Error: settings.python_interpreter must be set to a valid interpreter not {settings.python_interpreter}" - ) - popen_obj = subprocess.Popen( - [settings.python_interpreter, "-m", "runestone", "build"], - # The build must be run from the directory containing a ``conf.py`` and all the needed support files. - cwd="applications/{}/build/preview".format(request.application), - # Capture the build output as text in case of an error. - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - universal_newlines=True, - # Pass the modified environment which doesn't contain ``DBURL``. - env=env, - ) - stdout, stderr = popen_obj.communicate() - # If there was an error, return stdout and stderr from the build. - if popen_obj.returncode != 0: - return json.dumps( - "Error: Runestone build failed:\n\n" + stdout + "\n" + stderr - ) - - with open( - "applications/{}/build/preview/build/preview/index.html".format( - request.application - ), - "r", - encoding="utf-8", - ) as ixf: - src = ixf.read() - tree = html.fromstring(src) - if len(tree.cssselect(".runestone")) == 0: - src = "" - result = re.search( - "(.*)", src, flags=re.DOTALL - ) - if result: - ctext = result.group(1) - else: - component = tree.cssselect(".system-message") - if len(component) > 0: - ctext = html.tostring(component[0]).decode("utf-8") - logger.debug("error - ", ctext) - else: - ctext = "Error: Runestone content missing." - return json.dumps(ctext) - except Exception as ex: - return json.dumps("Error: {}".format(ex)) - - -def save_donate(): - if auth.user: - db(db.auth_user.id == auth.user.id).update(donated=True) - - -def did_donate(): - if auth.user: - d_status = ( - db(db.auth_user.id == auth.user.id).select(db.auth_user.donated).first() - ) - - return json.dumps(dict(donate=d_status.donated)) - return json.dumps(dict(donate=False)) - - -def get_datafile(): - """ - course_id - string, the name of the course - acid - the acid of this datafile - """ - course = request.vars.course_id # the course name - the_course = db(db.courses.course_name == course).select(**SELECT_CACHE).first() - acid = request.vars.acid - datafile = ( - db( - (db.source_code.acid == acid) - & ( - (db.source_code.course_id == the_course.base_course) - | (db.source_code.course_id == course) - ) - ) - .select(db.source_code) - .first() - ) - - if datafile: - file_name = datafile.filename - file_contents = datafile.main_code - else: - file_name = "" - file_contents = None - - return json.dumps(dict(data=file_contents, filename=file_name)) - - @auth.requires( lambda: verifyInstructorStatus(auth.user.course_name, auth.user), requires_login=True, @@ -1583,276 +152,3 @@ def broadcast_code(): counter += 1 return json.dumps(dict(mess="success", share_count=counter)) - - -def _same_class(user1: str, user2: str) -> bool: - user1_course = ( - db(db.auth_user.username == user1).select(db.auth_user.course_id).first() - ) - user2_course = ( - db(db.auth_user.username == user2).select(db.auth_user.course_id).first() - ) - - return user1_course == user2_course - - -def login_status(): - if auth.user: - return json.dumps(dict(status="loggedin", course_name=auth.user.course_name)) - else: - return json.dumps(dict(status="loggedout", course_name=auth.user.course_name)) - - -auto_gradable_q = [ - "clickablearea", - "mchoice", - "parsonsprob", - "dragndrop", - "fillintheblank", - "quizly", - "khanex", -] - - -def get_question_source(): - """Called from the selectquestion directive - There are 4 cases: - - 1. If there is only 1 question in the question list then return the html source for it. - 2. If there are multiple questions then choose a question at random - 3. If a proficiency is selected then select a random question that tests that proficiency - 4. If the question is an AB question then see if this student is an A or a B or assign them to one randomly. - - In the last two cases, first check to see if there is a question for this student for this - component that was previously selected. - - Returns: - json: html source for this question - """ - prof = False - points = request.vars.points or 0 # no nulls allowed - logger.debug(f"POINTS = {points}") - min_difficulty = request.vars.min_difficulty - max_difficulty = request.vars.max_difficulty - not_seen_ever = request.vars.not_seen_ever - autogradable = request.vars.autogradable - is_primary = request.vars.primary - is_ab = request.vars.AB - selector_id = request.vars["selector_id"] - assignment_name = request.vars["timedWrapper"] - toggle = request.vars["toggleOptions"] - questionlist = [] - # If the question has a :points: option then those points are the default - # however sometimes questions are entered in the web ui without the :points: - # and points are assigned in the UI instead. If this is part of an - # assignment or timed exam AND the points are set in the web UI we will - # use the points from the UI over the :points: If this is an assignment - # or exam that is totally written in RST then the points in the UI will match - # the points from the assignment anyway. - if assignment_name: - ui_points = ( - db( - (db.assignments.name == assignment_name) - & (db.assignments.id == db.assignment_questions.assignment_id) - & (db.assignment_questions.question_id == db.questions.id) - & (db.questions.name == selector_id) - ) - .select(db.assignment_questions.points) - .first() - ) - logger.debug( - f"Assignment Points for {assignment_name}, {selector_id} = {ui_points}" - ) - if ui_points: - points = ui_points.points - - if request.vars["questions"]: - questionlist = request.vars["questions"].split(",") - questionlist = [q.strip() for q in questionlist] - elif request.vars["proficiency"]: - prof = request.vars["proficiency"] - - query = (db.competency.competency == prof) & ( - db.competency.question == db.questions.id - ) - if is_primary: - query = query & (db.competency.is_primary == True) - if min_difficulty: - query = query & (db.questions.difficulty >= float(min_difficulty)) - if max_difficulty: - query = query & (db.questions.difficulty <= float(max_difficulty)) - if autogradable: - query = query & ( - (db.questions.autograde == "unittest") - | db.questions.question_type.contains(auto_gradable_q, all=False) - ) - res = db(query).select(db.questions.name) - logger.debug(f"Query was {db._lastsql}") - if res: - questionlist = [row.name for row in res] - else: - questionlist = [] - logger.error(f"No questions found for proficiency {prof}") - return json.dumps(f"

No Questions found for proficiency: {prof}

") - - if not auth.user: - # user is not logged in so just give them a random question from questions list - # and be done with it. - if questionlist: - q = random.choice(questionlist) - res = db(db.questions.name == q).select(db.questions.htmlsrc).first() - if res: - return json.dumps(res.htmlsrc) - else: - return json.dumps(f"

Question {q} is not in the database.

") - else: - return json.dumps(f"

No Questions available

") - - logger.debug(f"is_ab is {is_ab}") - if is_ab: - - res = db( - (db.user_experiment.sid == auth.user.username) - & (db.user_experiment.experiment_id == is_ab) - ).select(orderby=db.user_experiment.id) - - if not res: - exp_group = random.randrange(2) - db.user_experiment.insert( - sid=auth.user.username, experiment_id=is_ab, exp_group=exp_group - ) - logger.debug(f"added {auth.user.username} to {is_ab} group {exp_group}") - - else: - exp_group = res[0].exp_group - - logger.debug(f"experimental group is {exp_group}") - - prev_selection = ( - db( - (db.selected_questions.sid == auth.user.username) - & (db.selected_questions.selector_id == selector_id) - ) - .select() - .first() - ) - - if prev_selection: - questionid = prev_selection.selected_id - else: - questionid = questionlist[exp_group] - - if not is_ab: - poss = set() - if not_seen_ever: - seenq = db( - (db.useinfo.sid == auth.user.username) - & (db.useinfo.div_id.contains(questionlist, all=False)) - ).select(db.useinfo.div_id) - seen = set([x.div_id for x in seenq]) - poss = set(questionlist) - questionlist = list(poss - seen) - - if len(questionlist) == 0 and len(poss) > 0: - questionlist = list(poss) - - htmlsrc = "" - - prev_selection = ( - db( - (db.selected_questions.sid == auth.user.username) - & (db.selected_questions.selector_id == selector_id) - ) - .select() - .first() - ) - - if prev_selection: - questionid = prev_selection.selected_id - else: - # Eliminate any previous exam questions for this student - prev_questions = db(db.selected_questions.sid == auth.user.username).select( - db.selected_questions.selected_id - ) - prev_questions = set([row.selected_id for row in prev_questions]) - possible = set(questionlist) - questionlist = list(possible - prev_questions) - if questionlist: - questionid = random.choice(questionlist) - else: - # If there are no questions left we should still return a random question. - questionid = random.choice(list(possible)) - - logger.debug(f"toggle is {toggle}") - if toggle: - prev_selection = ( - db( - (db.selected_questions.sid == auth.user.username) - & (db.selected_questions.selector_id == selector_id) - ) - .select() - .first() - ) - if prev_selection: - questionid = prev_selection.selected_id - else: - questionid = request.vars["questions"].split(",")[0] - # else: - # logger.error( - # f"Question ID '{questionid}' not found in select question list of '{selector_id}'." - # ) - # return json.dumps( - # f"

Question ID '{questionid}' not found in select question list of '{selector_id}'.

" - # ) - - res = db((db.questions.name == questionid)).select(db.questions.htmlsrc).first() - - if res and not prev_selection: - qid = db.selected_questions.insert( - selector_id=selector_id, - sid=auth.user.username, - selected_id=questionid, - points=points, - ) - if not qid: - logger.error( - f"Failed to insert a selected question for {selector_id} and {auth.user.username}" - ) - else: - logger.debug( - f"Did not insert a record for {selector_id}, {questionid} Conditions are {res} QL: {questionlist} PREV: {prev_selection}" - ) - - if res and res.htmlsrc: - htmlsrc = res.htmlsrc - else: - logger.error( - f"HTML Source not found for {questionid} in course {auth.user.course_name} for {auth.user.username}" - ) - htmlsrc = "

No preview available

" - return json.dumps(htmlsrc) - - -@auth.requires_login() -def update_selected_question(): - """ - This endpoint is used by the selectquestion problems that allow the - student to select the problem they work on. For example they may have - a programming problem that can be solved with writing code, or they - can switch to a parsons problem if necessary. - - Caller must provide: - * ``metaid`` -- the id of the selectquestion - * ``selected`` -- the id of the real question chosen by the student - """ - sid = auth.user.username - selector_id = request.vars.metaid - selected_id = request.vars.selected - logger.debug(f"USQ - {selector_id} --> {selected_id} for {sid}") - db.selected_questions.update_or_insert( - (db.selected_questions.selector_id == selector_id) - & (db.selected_questions.sid == sid), - selected_id=selected_id, - selector_id=selector_id, - sid=sid, - ) diff --git a/bases/rsptx/web2py_server/applications/runestone/controllers/books.py b/bases/rsptx/web2py_server/applications/runestone/controllers/books.py deleted file mode 100644 index ca87f0bd2..000000000 --- a/bases/rsptx/web2py_server/applications/runestone/controllers/books.py +++ /dev/null @@ -1,427 +0,0 @@ -# ******************************* -# |docname| - route to a textbook -# ******************************* -# This controller provides routes to a specific textbook page. -# -# The expected URL is: -# -## base_course/path/subpath/.../book.html -## args[0] 1 2 ... len(args) - 1 - - -# Imports -# ======= -# These are listed in the order prescribed by `PEP 8 -# `_. -# -# Standard library -# ---------------- -import os -import posixpath -import json -import logging -import datetime -import pathlib -import random -import re - -logger = logging.getLogger(settings.logger) -logger.setLevel(settings.log_level) - -# Third-party imports -# ------------------- -# None. -# -# Local application imports -# ------------------------- -# None. - - -# Supporting functions -# ==================== -# THIS FUNCTION IS DEPRECATED -def _route_book(is_published=True): - # Get the base course passed in ``request.args[0]``, or return a 404 if that argument is missing. - base_course = request.args(0) - motd = "" - donated = False - attrdict = {} - settings.show_rs_banner = False - if not base_course: - raise HTTP(404) - - # Does this book originate as a Runestone book or a PreTeXt book. - # if it is pretext book we use different delimiters for the templates - # as LaTeX is full of {{ - # These values are set by the runestone process-manifest command - res = getCourseOrigin(base_course) - if res and res.value == "PreTeXt": - response.delimiters = settings.pretext_delimiters - - # See `caching selects `_. - cache_kwargs = dict(cache=(cache.ram, 3600), cacheable=True) - allow_pairs = "false" - downloads_enabled = "false" - # Find the course to access. - if auth.user: - # Given a logged-in user, use ``auth.user.course_id``. - response.cookies["last_course"] = auth.user.course_name - response.cookies["last_course"]["expires"] = 24 * 3600 * 90 # 90 day expiration - response.cookies["last_course"]["path"] = "/" - - # Get `course info `. - course = ( - db(db.courses.id == auth.user.course_id) - .select( - db.courses.id, - db.courses.course_name, - db.courses.base_course, - db.courses.allow_pairs, - db.courses.downloads_enabled, - db.courses.term_start_date, - db.courses.courselevel, - **cache_kwargs, - ) - .first() - ) - if auth.user.donated: - donated = True - - # Ensure the base course in the URL agrees with the base course in ``course``. - # If not, ask the user to select a course. - if not course or course.base_course != base_course: - session.flash = "{} is not the course you are currently in, switch to or add it to go there".format( - base_course - ) - redirect(URL(c="default", f="courses")) - - attrdict = getCourseAttributesDict(course.id) - # set defaults for various attrs - if "enable_compare_me" not in attrdict: - attrdict["enable_compare_me"] = "true" - - # Determine if we should ask for support - # Trying to do banner ads during the 2nd and 3rd weeks of the term - # but not to high school students or if the instructor has donated for the course - now = datetime.datetime.utcnow().date() - week2 = datetime.timedelta(weeks=2) - week4 = datetime.timedelta(weeks=4) - if ( - now >= (course.term_start_date + week2) - and now <= (course.term_start_date + week4) - and course.base_course != "csawesome" - and course.courselevel != "high" - and getCourseAttribute(course.id, "supporter") is None - ): - settings.show_rs_banner = True - elif course.course_name == course.base_course and random.random() <= 0.2: - # Show banners to base course users 20% of the time. - settings.show_rs_banner = True - - allow_pairs = "true" if course.allow_pairs else "false" - downloads_enabled = "true" if course.downloads_enabled else "false" - # Ensure the user has access to this book. - if ( - is_published - and not db( - (db.user_courses.user_id == auth.user.id) - & (db.user_courses.course_id == auth.user.course_id) - ) - .select(db.user_courses.id, **cache_kwargs) - .first() - ): - session.flash = "Sorry you are not registered for this course. You can view most Open courses if you log out" - redirect(URL(c="default", f="courses")) - - else: - # Get the base course from the URL. - if "last_course" in request.cookies: - last_base = ( - db(db.courses.course_name == request.cookies["last_course"].value) - .select(db.courses.base_course) - .first() - ) - if last_base and last_base.base_course == base_course: - # The user is trying to access the base course for the last course they logged in to - # there is a 99% chance this is an error and we should make them log in. - session.flash = "You most likely want to log in to access your course" - redirect(URL(c="default", f="courses")) - response.serve_ad = True - course = ( - db(db.courses.course_name == base_course) - .select( - db.courses.id, - db.courses.course_name, - db.courses.base_course, - db.courses.login_required, - db.courses.allow_pairs, - db.courses.downloads_enabled, - **cache_kwargs, - ) - .first() - ) - - if not course: - # This course doesn't exist. - raise HTTP(404) - - attrdict = getCourseAttributesDict(course.id) - # set defaults for various attrs - if "enable_compare_me" not in attrdict: - attrdict["enable_compare_me"] = "true" - - # Require a login if necessary. - if course.login_required: - # Ask for a login by invoking the auth decorator. - @auth.requires_login() - def dummy(): - pass - - dummy() - # This code should never run! - assert False - - # Make this an absolute path. - book_path = safe_join( - os.path.join( - request.folder, - "books", - base_course, - "published" if is_published else "build", - base_course, - ), - *request.args[1:], - ) - if not book_path: - logger.error("No Safe Path for {}".format(request.args[1:])) - raise HTTP(404) - - # See if this is static content. By default, the Sphinx static directory names are ``_static`` and ``_images``. - if request.args(1) in ["_static", "_images"] or book_path.endswith( - ("css", "png", "jpg") - ): - # See the `response `_. - # Warning: this is slow. Configure a production server to serve this statically. - return response.stream(book_path, 2**20, request=request) - - # It's HTML -- use the file as a template. - # - # Make sure the file exists. Otherwise, the rendered "page" will look goofy. - if not os.path.isfile(book_path): - logger.error("Bad Path for {} given {}".format(book_path, request.args[1:])) - raise HTTP(404) - response.view = book_path - chapter = os.path.split(os.path.split(book_path)[0])[1] - subchapter = os.path.basename(os.path.splitext(book_path)[0]) - div_counts = {} - if auth.user: - user_id = auth.user.username - email = auth.user.email - is_logged_in = "true" - # Get the necessary information to update subchapter progress on the page - page_divids = db( - (db.questions.subchapter == subchapter) - & (db.questions.chapter == chapter) - & (db.questions.from_source == True) # noqa: E712 - & ((db.questions.optional == False) | (db.questions.optional == None)) - & (db.questions.base_course == base_course) - ).select(db.questions.name) - div_counts = {q.name: 0 for q in page_divids} - sid_counts = db( - (db.questions.subchapter == subchapter) - & (db.questions.chapter == chapter) - & (db.questions.base_course == base_course) - & (db.questions.from_source == True) # noqa: E712 - & ((db.questions.optional == False) | (db.questions.optional == None)) - & (db.questions.name == db.useinfo.div_id) - & (db.useinfo.course_id == auth.user.course_name) - & (db.useinfo.sid == auth.user.username) - ).select(db.useinfo.div_id, distinct=True) - for row in sid_counts: - div_counts[row.div_id] = 1 - else: - user_id = "Anonymous" - email = "" - is_logged_in = "false" - - if session.readings: - reading_list = session.readings - else: - reading_list = "null" - - try: - db.useinfo.insert( - sid=user_id, - act="view", - div_id=book_path, - event="page", - timestamp=datetime.datetime.utcnow(), - course_id=course.course_name, - ) - except Exception as e: - logger.error( - "failed to insert log record for {} in {} : {} {} {}".format( - user_id, course.course_name, book_path, "page", "view" - ) - ) - logger.error("Database Error Detail: {}".format(e)) - - user_is_instructor = ( - "true" - if auth.user and verifyInstructorStatus(auth.user.course_name, auth.user) - else "false" - ) - - # Support Runestone Campaign - # - # settings.show_rs_banner = True # debug only - banner_num = None - if donated: - banner_num = 0 # Thank You Banner - else: - if settings.num_banners > 0: - banner_num = random.randrange( - 1, settings.num_banners + 1 - ) # select a random banner - else: - settings.show_rs_banner = False - - questions = None - if subchapter == "Exercises": - questions = _exercises(base_course, chapter) - logger.debug("QUESTIONS = {} {}".format(subchapter, questions)) - return dict( - course_name=course.course_name, - base_course=base_course, - is_logged_in=is_logged_in, - user_id=user_id, - user_email=email, - is_instructor=user_is_instructor, - allow_pairs=allow_pairs, - readings=XML(reading_list), - activity_info=json.dumps(div_counts), - downloads_enabled=downloads_enabled, - subchapter_list=_subchaptoc(base_course, chapter), - questions=questions, - motd=motd, - banner_num=banner_num, - **attrdict, - ) - - -def _subchaptoc(course, chap): - res = db( - (db.chapters.id == db.sub_chapters.chapter_id) - & (db.chapters.course_id == course) - & (db.chapters.chapter_label == chap) - ).select( - db.sub_chapters.sub_chapter_label, - db.sub_chapters.sub_chapter_name, - orderby=db.sub_chapters.sub_chapter_num, - cache=(cache.ram, 3600), - cacheable=True, - ) - toclist = [] - for row in res: - sc_url = "{}.html".format(row.sub_chapter_label) - title = row.sub_chapter_name - toclist.append(dict(subchap_uri=sc_url, title=title)) - - return toclist - - -def _exercises(basecourse, chapter): - """ - Given a base course and a chapter return the instructor generated questions - for the Exercises subchapter. - - """ - print("{} {}".format(chapter, basecourse)) - questions = db( - (db.questions.chapter == chapter) - & (db.questions.subchapter == "Exercises") - & (db.questions.base_course == basecourse) - & (db.questions.is_private == "F") - & (db.questions.from_source == "F") - & ( - (db.questions.review_flag != "T") | (db.questions.review_flag == None) - ) # noqa: E711 - ).select( - db.questions.htmlsrc, - db.questions.author, - db.questions.difficulty, - db.questions.qnumber, - orderby=db.questions.timestamp, - ) - return questions - - -# This is copied verbatim from https://github.com/pallets/werkzeug/blob/master/werkzeug/security.py#L30. -_os_alt_seps = list( - sep for sep in [os.path.sep, os.path.altsep] if sep not in (None, "/") -) - - -# This is copied verbatim from https://github.com/pallets/werkzeug/blob/master/werkzeug/security.py#L216. -def safe_join(directory, *pathnames): - """Safely join `directory` and one or more untrusted `pathnames`. If this - cannot be done, this function returns ``None``. - :param directory: the base directory. - :param pathnames: the untrusted pathnames relative to that directory. - """ - parts = [directory] - for filename in pathnames: - if filename != "": - filename = posixpath.normpath(filename) - for sep in _os_alt_seps: - if sep in filename: - return None - if os.path.isabs(filename) or filename == ".." or filename.startswith("../"): - return None - parts.append(filename) - return posixpath.join(*parts) - - -# Endpoints -# ========= -# This serves pages directly from the book's build directory. Therefore, restrict access. -@auth.requires( - lambda: verifyInstructorStatus(auth.user.course_name, auth.user), - requires_login=True, -) -def draft(): - return _route_book(False) - - -# Serve from the ``published`` directory, instead of the ``build`` directory. -def published(): - if len(request.args) == 0: - return index() - return _route_book() - - -def index(): - """ - Called by default (and by published if no args) - - Produce a list of books based on the directory structure of runestone/books - - TODO: Port this to books in bookserver - - """ - - redirect("/ns/books/index") - - -# -# PreTeXt books are set up with an index.thml that meta refreshes to the real start page. -# This is great for flexibility but not good for ?mode=browsing or for SEO scores. -# We can parse the "real" home page for the book from index.html -def _find_real_url(libdir, book): - idx = pathlib.Path(libdir, book, "published", book, "index.html") - if idx.exists(): - with open(idx, "r") as idxf: - for line in idxf: - if g := re.search(r"refresh.*URL='(.*?)'", line): - return g.group(1) - return "index.html" diff --git a/bases/rsptx/web2py_server/applications/runestone/controllers/designer.py b/bases/rsptx/web2py_server/applications/runestone/controllers/designer.py deleted file mode 100644 index fc8373f0f..000000000 --- a/bases/rsptx/web2py_server/applications/runestone/controllers/designer.py +++ /dev/null @@ -1,176 +0,0 @@ -# -*- coding: utf-8 -*- -# this file is released under public domain and you can use without limitations -from os import path -import random -import datetime -import logging - -logger = logging.getLogger(settings.logger) -logger.setLevel(settings.log_level) - -admin_logger(logger) -######################################################################### -## This is a samples controller -## - index is the default action of any application -## - user is required for authentication and authorization -## - download is for downloading files uploaded in the db (does streaming) -## - call exposes all registered services (none by default) -######################################################################### - - -@auth.requires_login() -def index(): - basicvalues = {} - # This page is obsolete -- redirect to the new admin - redirect("/admin/instructor/create_course") - if settings.academy_mode: - """ - example action using the internationalization operator T and flash - rendered by views/default/index.html or views/generic.html - """ - # response.flash = "Welcome to CourseWare Manager!" - - basicvalues["message"] = T("Build a Custom Course") - basicvalues["descr"] = T( - """This page allows you to select a book for your own class. You will have access to all student activities in your course. - To begin, enter a project name below.""" - ) - # return dict(message=T('Welcome to CourseWare Manager')) - course_list = db.executesql( - "select * from library where for_classes = 'T'", - as_dict=True, - ) - sections = set() - for course in course_list: - if course["shelf_section"] == None: - course["shelf_section"] = "Uncategorized" - # if the shelf_section is not in sections, add it - if course["shelf_section"] not in sections: - sections.add(course["shelf_section"]) - - basicvalues["course_list"] = course_list - basicvalues["sections"] = sections - - return basicvalues - - -@auth.requires_login() -def build(): - buildvalues = {} - if settings.academy_mode: - buildvalues["pname"] = request.vars.projectname - buildvalues["pdescr"] = request.vars.projectdescription - - existing_course = ( - db(db.courses.course_name == request.vars.projectname).select().first() - ) - if existing_course: - session.flash = ( - f"course name {request.vars.projectname} has already been used" - ) - redirect(URL("designer", "index")) - - if not request.vars.coursetype: - session.flash = "You must select a base course." - redirect(URL("designer", "index")) - - # if make instructor add row to auth_membership - if "instructor" in request.vars: - gid = ( - db(db.auth_group.role == "instructor").select(db.auth_group.id).first() - ) - db.auth_membership.insert(user_id=auth.user.id, group_id=gid) - - base_course = request.vars.coursetype - bcdb = db(db.courses.course_name == base_course).select().first() - if request.vars.startdate == "": - request.vars.startdate = datetime.date.today() - else: - date = request.vars.startdate.split("/") - request.vars.startdate = datetime.date( - int(date[2]), int(date[0]), int(date[1]) - ) - - if not request.vars.institution: - institution = "Not Provided" - else: - institution = request.vars.institution - - if not request.vars.courselevel: - courselevel = "unknown" - else: - courselevel = request.vars.courselevel - - python3 = "true" - - if not request.vars.loginreq: - login_required = "false" - else: - login_required = "true" - - if request.vars.domainname: - domainname = request.vars.domainname - else: - domainname = None - - # TODO: Update new_server after full away from old server - cid = db.courses.update_or_insert( - course_name=request.vars.projectname, - term_start_date=request.vars.startdate, - institution=institution, - base_course=base_course, - login_required=login_required, - python3=python3, - courselevel=courselevel, - state=request.vars.state, - new_server=True, - domain_name=domainname, - ) - - origin = getCourseOrigin(base_course) - if origin and origin.value == "PreTeXt": - origin_attrs = getCourseAttributesDict(bcdb.id, base_course) - for key in origin_attrs: - db.course_attributes.insert( - course_id=cid, attr=key, value=origin_attrs[key] - ) - - if request.vars.invoice: - db.invoice_request.insert( - timestamp=datetime.datetime.now(), - sid=auth.user.username, - email=auth.user.email, - course_name=request.vars.projectname, - ) - - # enrol the user in their new course - db(db.auth_user.id == auth.user.id).update(course_id=cid) - db.course_instructor.insert(instructor=auth.user.id, course=cid) - auth.user.update( - course_name=request.vars.projectname - ) # also updates session info - auth.user.update(course_id=cid) - db.executesql( - """ - INSERT INTO user_courses(user_id, course_id) - SELECT %s, %s - """, - (auth.user.id, cid), - ) - - # library_row = db(db.library.base_course == base_course).select().first() - # We do not have library defined as a model so just do it raw sql - res = db.executesql( - "select social_url from library where basecourse = %s", (base_course,) - ) - social_url = res[0][0] if res else None - session.flash = "Course Created Successfully" - # redirect( - # URL("books", "published", args=[request.vars.projectname, "index.html"]) - # ) - - return dict( - coursename=request.vars.projectname, - basecourse=base_course, - social_url=social_url, - ) diff --git a/bases/rsptx/web2py_server/applications/runestone/controllers/exams.py b/bases/rsptx/web2py_server/applications/runestone/controllers/exams.py deleted file mode 100644 index bb31145b0..000000000 --- a/bases/rsptx/web2py_server/applications/runestone/controllers/exams.py +++ /dev/null @@ -1,112 +0,0 @@ -# ********************************************* -# |docname| - Endpoints relating to assignments -# ********************************************* -# -# Imports -# ======= -# These are listed in the order prescribed by `PEP 8 -# `_. -# -# Standard library -# ---------------- -import json -import logging -import datetime -from collections import OrderedDict -import traceback -import logging - -# Third-party imports -# ------------------- -from psycopg2 import IntegrityError -import pandas as pd -import altair as alt - -# Local application imports -# ------------------------- - -logger = logging.getLogger(settings.logger) -logger.setLevel(settings.log_level) - - -@auth.requires( - lambda: verifyInstructorStatus(auth.user.course_name, auth.user), - requires_login=True, -) -def one_exam_competency(): - """Get data about one exam - using a very cool query. - - """ - - assignment_name = request.vars.assignment - - if not assignment_name: - session.flash = "You must provide an assignment ID" - return redirect(redirect(URL("admin", "admin"))) - - course_id = auth.user.course_id - - logger.debug(f"COMP REPORT for {assignment_name} , {course_id}") - query = f""" - SELECT course, assignments.name, question_grades.sid, div_id, score, selected_questions.points, selected_id, competency.competency, is_primary - FROM assignments JOIN assignment_questions ON assignment_id = assignments.id - JOIN questions ON question_id = questions.id - JOIN question_grades ON questions.name = div_id - JOIN selected_questions ON selector_id = div_id AND question_grades.sid = selected_questions.sid - JOIN competency ON selected_id = question_name - WHERE assignments.name='{assignment_name}' AND course={course_id}; - """ - - clx = pd.read_sql_query( - query, settings.database_uri.replace("postgres://", "postgresql://") - ) - clx["pct"] = clx.score / clx.points - clx["correct"] = clx.pct.map(lambda x: 1 if x > 0.9 else 0) - - logging.debug(clx) - glx = ( - clx[clx.is_primary == "T"] - .groupby(["competency", "sid"]) - .agg(num_correct=("correct", "sum"), num_answers=("correct", "count")) - ) - glx = glx.reset_index() - glx["pct_correct"] = glx.num_correct / glx.num_answers - c = ( - alt.Chart(glx, title="Primary Competencies") - .mark_rect() - .encode( - x="sid:O", - y="competency", - color=alt.Color("pct_correct", scale=alt.Scale(scheme="blues")), - tooltip="num_answers", - ) - ) - pcdata = c.to_json() - # Supporting - glx = ( - clx[clx.is_primary == "F"] - .groupby(["competency", "sid"]) - .agg(num_correct=("correct", "sum"), num_answers=("correct", "count")) - ) - glx = glx.reset_index() - glx["pct_correct"] = glx.num_correct / glx.num_answers - c = ( - alt.Chart(glx, title="Supplemental Competencies") - .mark_rect() - .encode( - x="sid:O", - y="competency", - color=alt.Color("pct_correct", scale=alt.Scale(scheme="reds")), - tooltip="num_answers", - ) - ) - - scdata = c.to_json() - - return dict( - course_id=auth.user.course_name, - course=get_course_row(db.courses.ALL), - pcdata=pcdata, - scdata=scdata, - ) diff --git a/bases/rsptx/web2py_server/applications/runestone/controllers/proxy.py b/bases/rsptx/web2py_server/applications/runestone/controllers/proxy.py deleted file mode 100644 index a00401d70..000000000 --- a/bases/rsptx/web2py_server/applications/runestone/controllers/proxy.py +++ /dev/null @@ -1,122 +0,0 @@ -import requests as rq -import logging - -# NOTE -# This file should no longer be used Proxying is handled by the book service. -# This file is only here for legacy reasons to support old books... I will delete it soon. - -logger = logging.getLogger(settings.logger) -logger.setLevel(settings.log_level) - -response.headers["Access-Control-Allow-Headers"] = ( - "Content-Type, Access-Control-Allow-Headers, Authorization, X-Requested-With" -) -response.headers["Access-Control-Allow-Methods"] = "GET, PUT, POST, HEAD, OPTIONS" - - -# Using this function makes the runestone proxy act like a load balancer -# for using more than one jobe server. -# -def get_jobe_server(): - if settings.num_jobes: - num_servers = settings.num_jobes - else: - num_servers = 1 - - if auth.user: - servernum = auth.user.id % num_servers - elif request.client: - servernum = hash(request.client) - servernum = servernum % num_servers - else: - servernum = 0 - logger.debug(f"SERVER SELECTED = {servernum} for {request.client}") - try: - server = settings.jobe_server.format(servernum) - except: - server = settings.jobe_server - - return server - - -def jobeRun(): - req = rq.Session() - logger.debug("got a jobe request %s", request.vars.run_spec) - - req.headers["Content-type"] = "application/json; charset=utf-8" - req.headers["Accept"] = "application/json" - if settings.jobe_key: - req.headers["X-API-KEY"] = settings.jobe_key - - uri = "/jobe/index.php/restapi/runs/" - url = get_jobe_server() + uri - rs = {"run_spec": request.vars.run_spec} - resp = req.post(url, json=rs) - - logger.debug("Got response from JOBE %s ", resp.status_code) - return resp.content - - -def jobePushFile(): - req = rq.Session() - logger.debug("got a jobe request %s", request.vars.run_spec) - - req.headers["Content-type"] = "application/json; charset=utf-8" - req.headers["Accept"] = "application/json" - req.headers["X-API-KEY"] = settings.jobe_key - - uri = "/jobe/index.php/restapi/files/" + request.args[0] - url = get_jobe_server() + uri - rs = {"file_contents": request.vars.file_contents} - resp = req.put(url, json=rs) - - logger.debug("Got response from JOBE %s ", resp.status_code) - - response.status = resp.status_code - return resp.content - - -def jobeCheckFile(): - req = rq.Session() - logger.debug("got a jobe request %s", request.vars.run_spec) - - req.headers["Content-type"] = "application/json; charset=utf-8" - req.headers["Accept"] = "application/json" - req.headers["X-API-KEY"] = settings.jobe_key - uri = "/jobe/index.php/restapi/files/" + request.args[0] - url = get_jobe_server() + uri - resp = req.head(url) - logger.debug("Got response from JOBE %s ", resp.status_code) - - response.status = resp.status_code - if resp.status_code == 404: - response.status = 208 - - return resp.content - - -def pytutor_trace(): - code = request.vars.code - lang = request.vars.lang - response.headers["Content-Type"] = "application/json; charset=utf-8" - if request.vars.stdin: - stdin = request.vars.stdin - else: - stdin = "" - - url = f"http://tracer.runestone.academy:5000/trace{lang}" - try: - r = rq.post(url, data=dict(src=code, stdin=stdin), timeout=30) - except rq.ReadTimeout: - logger.error( - "The request to the trace server timed out, you will need to rerun the build" - ) - return "" - if r.status_code == 200: - if lang == "java": - return r.text - else: - res = r.text[r.text.find('{"code":') :] - return res - logger.error(f"Unknown error occurred while getting trace {r.status_code}") - return "Error in pytutor_trace" diff --git a/bases/rsptx/web2py_server/applications/runestone/controllers/toctree.rst b/bases/rsptx/web2py_server/applications/runestone/controllers/toctree.rst deleted file mode 100644 index 0b48ec88e..000000000 --- a/bases/rsptx/web2py_server/applications/runestone/controllers/toctree.rst +++ /dev/null @@ -1,9 +0,0 @@ -****************** -Web2py controllers -****************** - -.. toctree:: - :maxdepth: 2 - :glob: - - *.py From adf37dd99fd348c338e035538a0d07afeef9ac8e Mon Sep 17 00:00:00 2001 From: Bradley Miller Date: Fri, 12 Jun 2026 17:43:06 -0500 Subject: [PATCH 3/4] Add Caddy reverse proxy as an HTTPS-capable alternative to nginx Adds a new projects/caddy/ service that mirrors the routing in projects/nginx/runestone one-for-one, but uses Caddy so HTTPS works with minimal hassle: automatic Let's Encrypt for a real domain, or a locally-trusted cert for dev via CADDY_SITE_ADDRESS. - projects/caddy/{Caddyfile,Dockerfile,README.md}: full routing + docs - docker-compose.yml: caddy service (+ caddy_data/caddy_config volumes) - sample.env: documented CADDY_SITE_ADDRESS knob The /ns exclusion that nginx did with a negative lookahead (unsupported by Go's RE2) is reproduced via handler ordering; the 25 MiB upload limit on /ns matches nginx byte-for-byte. Verified all routes end-to-end against live containers. Co-Authored-By: Claude Opus 4.8 --- docker-compose.yml | 61 +++++++++++++++++- projects/caddy/Caddyfile | 132 ++++++++++++++++++++++++++++++++++++++ projects/caddy/Dockerfile | 15 +++++ projects/caddy/README.md | 67 +++++++++++++++++++ sample.env | 17 +++++ 5 files changed, 291 insertions(+), 1 deletion(-) create mode 100644 projects/caddy/Caddyfile create mode 100644 projects/caddy/Dockerfile create mode 100644 projects/caddy/README.md diff --git a/docker-compose.yml b/docker-compose.yml index ae4e7767e..4332992ae 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -271,7 +271,7 @@ services: required: false nginx: - + profiles: ['nginx'] # Note we use context: ./ here so that the Dockerfile can copy from the components folder build: context: ./ @@ -307,6 +307,59 @@ services: condition: service_started required: false + # Caddy is a drop-in alternative to the nginx service above. It performs the + # exact same routing (see projects/caddy/Caddyfile) but gives you HTTPS with + # no hassle: automatic Let's Encrypt for a real domain, or a locally-trusted + # cert for dev. Because it also wants ports 80/443 it lives in its own + # "caddy" profile so it never competes with nginx - run ONE of them, e.g. + # COMPOSE_PROFILES=basic,caddy docker compose up + # For local HTTPS set CADDY_SITE_ADDRESS=https://localhost in your .env; + # for a public site set CADDY_SITE_ADDRESS=https://your.domain. + caddy: + # profiles: [ "caddy" ] + # Note we use context: ./ here so that the Dockerfile can copy from the components folder + build: + context: ./ + dockerfile: projects/caddy/Dockerfile + image: ghcr.io/runestoneinteractive/rs-caddy + extra_hosts: + - host.docker.internal:host-gateway + restart: always + logging: + <<: *json-file-logging + options: + tag: runestone.caddy + environment: + # Unset -> listen on :80 only (parity with nginx). + # https://localhost -> local dev cert from Caddy's internal CA. + # https://your.domain -> automatic Let's Encrypt certificate. + - CADDY_SITE_ADDRESS=${CADDY_SITE_ADDRESS:-:80} + ports: + # ports are specified host:container + - 80:80 + - 443:443 + volumes: + - ${BOOK_PATH}:/usr/books + # Persist issued certificates and Caddy state across restarts. + - caddy_data:/data + - caddy_config:/config + depends_on: + runestone: + condition: service_started + required: true + book: + condition: service_started + required: true + assignment: + condition: service_started + required: true + admin: + condition: service_started + required: false + author: + condition: service_started + required: false + # this second nginx is designed to sit in front of python servers running on a host machine for a more convineient dev environment # it exposes the Runestone application on port 8080 nginx_dstart_dev: @@ -478,3 +531,9 @@ services: redis: condition: service_started required: true + +# Named volumes used by the optional caddy reverse-proxy service to persist +# TLS certificates and Caddy's own state between restarts. +volumes: + caddy_data: + caddy_config: diff --git a/projects/caddy/Caddyfile b/projects/caddy/Caddyfile new file mode 100644 index 000000000..9ecc39ab9 --- /dev/null +++ b/projects/caddy/Caddyfile @@ -0,0 +1,132 @@ +# Caddyfile - reverse proxy for the Runestone stack +# +# This is a drop-in replacement for projects/nginx/runestone. It performs the +# exact same routing, but uses Caddy so that HTTPS "just works": +# +# * Local dev: set CADDY_SITE_ADDRESS=https://localhost (Caddy issues a cert +# from its own internal CA - no certbot, no config). +# * Single-app: set CADDY_SITE_ADDRESS=https://your.domain.com and Caddy will +# automatically obtain and renew a Let's Encrypt certificate. +# (To receive expiry notices, add a global `email you@x.com` +# option block at the top of this file.) +# * Parity: leave CADDY_SITE_ADDRESS unset and Caddy listens on :80 only, +# behaving just like the nginx container. +# +# Everything below mirrors projects/nginx/runestone one-for-one. + +# -------------------------------------------------------------------------- +# All of the routing lives in this snippet so the same rules apply no matter +# what address (http :80, https://localhost, https://domain) we listen on. +# -------------------------------------------------------------------------- +(runestone_routes) { + # gzip_static on; -> serve precompressed files and compress on the fly. + encode gzip zstd + + # Custom-ish access log to stdout so `docker logs` shows requests. + log { + output stdout + format console + } + + # A `route` block runs its directives top-to-bottom in the order written, + # which is what lets us reproduce nginx's location precedence exactly. + route { + # ----- internal rewrites (no client redirect), same as nginx ----- + # rewrite ^/ads.txt /runestone/static/ads.txt; + @ads path /ads.txt + rewrite @ads /runestone/static/ads.txt + + # rewrite ^/runestone/static/JavaReview/(\w+)/(.*)$ /ns/books/published/apcsareview/$1/$2; + @javareview path_regexp jr ^/runestone/static/JavaReview/(\w+)/(.*)$ + rewrite @javareview /ns/books/published/apcsareview/{re.jr.1}/{re.jr.2} + + # rewrite ^/runestone/static/csawesome/(\w+)/(.*)$ /ns/books/published/csawesome/$1/$2; + @csawesome path_regexp csa ^/runestone/static/csawesome/(\w+)/(.*)$ + rewrite @csawesome /ns/books/published/csawesome/{re.csa.1}/{re.csa.2} + + # ----- shared static assets ----- + # location ~* ^/staticAssets/(.*)$ { alias /usr/share/nginx/html/staticAssets/$1; } + handle_path /staticAssets/* { + root * /srv/staticAssets + file_server + } + + # ----- new server (book server) ----- + # This MUST come before the book-static handlers below. nginx used a + # negative lookahead (?!ns/) to keep /ns/... out of the static-file + # match; Go's regexp engine has no lookahead, so we get the same effect + # by matching /ns/* first (handle blocks are terminal once matched). + # + # location /ns/ { proxy_pass http://book:8111/; client_max_body_size 25M; } + handle_path /ns/* { + # nginx's "25M" is 25 MiB (26214400 bytes); Caddy's "MB" is decimal, + # so use "MiB" to match byte-for-byte. + request_body { + max_size 25MiB + } + reverse_proxy book:8111 { + header_up X-Forwarded-Proto https + } + } + + # ----- book static files, application prefix present ----- + # location ~* ^/(?!ns/)(\w+)/books/(published|draft)/(\w+)/(_static|_images|images)/(.*)$ + # alias /usr/books/$3/$2/$3/$4/$5; + @book_app path_regexp bookapp ^/(\w+)/books/(published|draft)/(\w+)/(_static|_images|images)/(.*)$ + handle @book_app { + root * /usr/books + rewrite * /{re.bookapp.3}/{re.bookapp.2}/{re.bookapp.3}/{re.bookapp.4}/{re.bookapp.5} + file_server + } + + # ----- book static files, no application prefix ----- + # location ~* ^/books/(published|draft)/(\w+)/(_static|_images|images)/(.*)$ + # alias /usr/books/$2/$1/$2/$3/$4; + @book_noapp path_regexp booknoapp ^/books/(published|draft)/(\w+)/(_static|_images|images)/(.*)$ + handle @book_noapp { + root * /usr/books + rewrite * /{re.booknoapp.2}/{re.booknoapp.1}/{re.booknoapp.2}/{re.booknoapp.3}/{re.booknoapp.4} + file_server + } + + # ----- assignment server ----- + # location /assignment/ { proxy_pass http://assignment:8113/; } + handle_path /assignment/* { + reverse_proxy assignment:8113 { + header_up X-Forwarded-Proto https + } + } + + # ----- admin server ----- + # location /admin/ { proxy_pass http://admin:8115/; } + handle_path /admin/* { + reverse_proxy admin:8115 { + header_up X-Forwarded-Proto https + } + } + + # ----- author server (optional) ----- + # nginx keeps the /author prefix when proxying (proxy_pass without a + # trailing path), so we use `handle` (not handle_path) to preserve it. + # location /author/ { proxy_pass $aserver$request_uri; } + handle /author/* { + reverse_proxy author:8114 { + header_up X-Forwarded-Proto https + } + } + + # ----- everything else -> web2py (runestone) ----- + # location / { proxy_pass http://runestone:8112; } + handle { + reverse_proxy runestone:8112 + } + } +} + +# -------------------------------------------------------------------------- +# The actual site. Address is env-driven so the same image works for plain +# HTTP (parity with nginx), local HTTPS, or a public domain with Let's Encrypt. +# -------------------------------------------------------------------------- +{$CADDY_SITE_ADDRESS::80} { + import runestone_routes +} diff --git a/projects/caddy/Dockerfile b/projects/caddy/Dockerfile new file mode 100644 index 000000000..2f8e21b56 --- /dev/null +++ b/projects/caddy/Dockerfile @@ -0,0 +1,15 @@ +FROM caddy:2-alpine + +# The context for this Dockerfile is the root of the runestone repo +LABEL org.opencontainers.image.source=https://github.com/RunestoneInteractive/rs + +COPY projects/caddy/Caddyfile /etc/caddy/Caddyfile + +# Copy the shared staticAssets so they can be served directly by Caddy +# (mirrors what projects/nginx/Dockerfile does for nginx). +COPY components/rsptx/templates/staticAssets /srv/staticAssets + +# This image is an alternative to projects/nginx. It performs identical routing +# but uses Caddy so HTTPS works out of the box - automatic Let's Encrypt for a +# real domain, or a locally-trusted cert for dev. See projects/caddy/Caddyfile +# for the CADDY_SITE_ADDRESS / CADDY_ACME_EMAIL knobs. diff --git a/projects/caddy/README.md b/projects/caddy/README.md new file mode 100644 index 000000000..8b2806e0b --- /dev/null +++ b/projects/caddy/README.md @@ -0,0 +1,67 @@ +# Caddy reverse proxy + +This is a drop-in alternative to the `nginx` service. It performs the exact same +routing as `projects/nginx/runestone` (see `Caddyfile`), but uses +[Caddy](https://caddyserver.com/) so that HTTPS works with little to no setup. + +## Choosing how it listens + +The listen address is controlled by `CADDY_SITE_ADDRESS` in your `.env`: + +| Value | Behavior | +|-------|----------| +| _unset_ / `:80` | HTTP only — parity with the nginx container | +| `https://localhost` | Local-dev HTTPS using Caddy's **internal CA** (see trust step below) | +| `https://your.domain` | Automatic, publicly-trusted **Let's Encrypt** certificate | + +After changing `.env`, recreate the container so it picks up the new value: + +```bash +docker compose up -d caddy +``` + +## Local HTTPS: trusting the cert (`https://localhost`) + +With `CADDY_SITE_ADDRESS=https://localhost`, Caddy serves HTTPS using a +certificate signed by its own local root CA. Your browser doesn't know that root +yet, so you'll get a security warning (and with HSTS, some browsers refuse to let +you click through at all). Install Caddy's root certificate once to fix this: + +### macOS + +```bash +# Pull Caddy's local root CA out of the container... +docker compose cp caddy:/data/caddy/pki/authorities/local/root.crt /tmp/caddy-root.crt + +# ...and add it to the System keychain as a trusted root. +sudo security add-trusted-cert -d -r trustRoot \ + -k /Library/Keychains/System.keychain /tmp/caddy-root.crt +``` + +Then fully quit and reopen your browser and reload `https://localhost`. + +### Linux (Debian/Ubuntu) + +```bash +docker compose cp caddy:/data/caddy/pki/authorities/local/root.crt \ + /usr/local/share/ca-certificates/caddy-local-root.crt +sudo update-ca-certificates +``` + +Firefox keeps its own trust store — import the `root.crt` under +*Settings → Privacy & Security → Certificates → View Certificates → Authorities*. + +### Notes + +- The root cert lives in the `caddy_data` named volume, so it survives restarts. + If you remove that volume (`docker compose down -v` or deleting it), Caddy + generates a new root and you'll need to trust it again. +- Don't want to install anything? Just click through the browser warning — the + connection is still encrypted, it's only the trust chain that's unverified. + +## Public HTTPS (real domain) + +Set `CADDY_SITE_ADDRESS=https://your.domain` and make sure ports **80 and 443** +are reachable from the internet. Caddy will obtain and auto-renew a Let's Encrypt +certificate — no warnings, no manual trust. To receive expiry notices, add a +global `email you@example.com` option block at the top of the `Caddyfile`. diff --git a/sample.env b/sample.env index f083c258a..26a5149ec 100644 --- a/sample.env +++ b/sample.env @@ -105,6 +105,23 @@ RUNESTONE_HOST = localhost # If you want nginx to install a certificate # CERTBOT_EMAIL = myemail@foo.com +# --- Caddy reverse proxy (alternative to nginx) --- +# The caddy service (projects/caddy) is a drop-in replacement for nginx that +# gives you HTTPS with no certbot setup. The address below controls how it +# listens. Leave it commented out (or set to :80) for plain HTTP, same as nginx. +# +# :80 -> HTTP only (default, parity with nginx) +# https://localhost -> local dev HTTPS using Caddy's internal CA. +# Your browser won't trust this cert until you +# install Caddy's root, otherwise you'll get a +# security warning. Trust steps are in +# projects/caddy/README.md. +# https://books.yourschool.edu -> automatic, publicly-trusted Let's Encrypt +# cert. Requires ports 80 and 443 reachable +# from the internet so the ACME challenge works. +# +# CADDY_SITE_ADDRESS=https://localhost + # For setting production (or development) runtime parameters # any UVICORN options can be set as an environment variable using the UVICORN_ prefix # for gunicorn we can add additional runtime parameters with the GUNICORN_CMD_ARGS variable From ff683894802b630465b43efc734f50a1ae075e1d Mon Sep 17 00:00:00 2001 From: Bradley Miller Date: Fri, 12 Jun 2026 17:48:37 -0500 Subject: [PATCH 4/4] fix lint --- bases/rsptx/admin_server_api/routers/telemetry.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/bases/rsptx/admin_server_api/routers/telemetry.py b/bases/rsptx/admin_server_api/routers/telemetry.py index 567044f8f..14c4ea82b 100644 --- a/bases/rsptx/admin_server_api/routers/telemetry.py +++ b/bases/rsptx/admin_server_api/routers/telemetry.py @@ -7,13 +7,12 @@ in the payload. """ -from typing import List, Optional +from typing import List from fastapi import APIRouter from pydantic import BaseModel, Field from rsptx.db.crud import upsert_installation -from rsptx.logging import rslogger router = APIRouter( prefix="/telemetry",