CAT-SOOP is a flexible, programmable learning management system based on the Python programming language. https://catsoop.mit.edu
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

623 lines
21 KiB

  1. # This file is part of CAT-SOOP
  2. # Copyright (c) 2011-2020 by The CAT-SOOP Developers <catsoop-dev@mit.edu>
  3. #
  4. # This program is free software: you can redistribute it and/or modify it under
  5. # the terms of the GNU Affero General Public License as published by the Free
  6. # Software Foundation, either version 3 of the License, or (at your option) any
  7. # later version.
  8. #
  9. # This program is distributed in the hope that it will be useful, but WITHOUT
  10. # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
  11. # FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
  12. # details.
  13. #
  14. # You should have received a copy of the GNU Affero General Public License
  15. # along with this program. If not, see <http://www.gnu.org/licenses/>.
  16. """
  17. Utilities for managing courses (questions, handlers, statistics, etc)
  18. """
  19. import os
  20. import random
  21. import string
  22. import importlib
  23. import collections
  24. from datetime import timedelta
  25. from collections import OrderedDict
  26. from . import auth
  27. from . import time
  28. from . import loader
  29. from . import cslog
  30. from . import base_context
  31. importlib.reload(base_context)
  32. _nodoc = {"timedelta", "OrderedDict"}
  33. def _get(context, key, default, cast=lambda x: x):
  34. v = context.get(key, default)
  35. return cast(v(context) if isinstance(v, collections.Callable) else v)
  36. def get_manual_grading_entry(context, name):
  37. """
  38. Return the most recent manual grading entry associated with a given
  39. question
  40. **Parameters:**
  41. * `context`: the context associated with this request (from which the
  42. current user is retrieved)
  43. * `name`: the name of the question whose grades we want
  44. **Returns:** the most recent manual grading entry associated with the given
  45. question, or `None` if no grading entries exist. If a dictionary is
  46. returned, it will have the following keys:
  47. * `'qname'`: the name of the question being graded (will be the same as the
  48. given `name`)
  49. * `'grader'`: the name of the user who submitted the grade
  50. * `'score'`: a floating-point number between 0 and 1, representing the
  51. score
  52. * `'comments'`: a string containing the grader's comments, if any
  53. * `'timestamp'`: the time at which the grade was submitted, as a string
  54. from `catsoop.time.detailed_timestamp`
  55. """
  56. uname = context["cs_user_info"].get("username", "None")
  57. log = context["csm_cslog"].read_log(uname, context["cs_path_info"], "problemgrades")
  58. out = None
  59. for i in log:
  60. if i["qname"] == name:
  61. out = i
  62. return out
  63. def make_score_display(
  64. context, args, name, score=None, assume_submit=False, last_log=None
  65. ):
  66. """
  67. Helper function to generate the display that users should see for their
  68. score.
  69. The output depends on a number of constraints:
  70. If `csq_show_score` is `False`, then no score is displayed. In this case,
  71. if the user has submitted something, they will see a message indicating
  72. that a submission has been received. Otherwise, they will see nothing.
  73. If no score is given, the most recent score is looked up (either from the
  74. manual grading entries log or from the problem state log) and used.
  75. After determining the score, if a `csq_score_message` function is defined,
  76. it is called with the current score (a `float` between 0 and 1) passed in
  77. as its sole argument, and the result is returned. Otherwise, a default
  78. value is used.
  79. **Parameters:**
  80. * `context`: the context associated with this request
  81. * `args`: a dictionary representing the environment associated with this
  82. question (including variables defined within the &lt;question&gt; tag)
  83. * `name`: the name of the question
  84. * `score`: a float between 0 and ` representing the score we want to
  85. render, or `None` if we should look up a value from the logs
  86. **Optional Parameters:**
  87. * `assume_submit` (default `False`): if `True`, assume that the user has
  88. made a submission for purposes of rendering a response, even if the
  89. logs say otherwise
  90. **Returns:** a string containing HTML representing the rendered score
  91. """
  92. last_log = last_log or {}
  93. if not _get(args, "csq_show_score", True, bool):
  94. if name in last_log.get("scores", {}) or assume_submit:
  95. return "Submission received."
  96. else:
  97. return ""
  98. gmode = _get(args, "csq_grading_mode", "auto", str)
  99. if gmode == "manual" and score is None:
  100. log = get_manual_grading_entry(context, name)
  101. if log is not None:
  102. score = log["score"]
  103. elif score is None:
  104. score = last_log.get("scores", {}).get(name, None)
  105. if score is None:
  106. if name in last_log.get("scores", {}) or assume_submit:
  107. return "Grade not available."
  108. else:
  109. return ""
  110. c = args.get("csq_score_message", None)
  111. try:
  112. return c(score)
  113. except:
  114. colorthing = 255 * score
  115. r = max(0, 200 - colorthing)
  116. g = min(200, colorthing)
  117. s = score * 100
  118. return (
  119. '<span style="color:rgb(%d,%d,0);font-weight:bolder;">' "%.02f%%</span>"
  120. ) % (r, g, s)
  121. def read_checker_result(context, magic):
  122. """
  123. Helper function to load a "results" file from the checker and return the
  124. associated dictionary.
  125. **Parameters:**
  126. * `context`: the context associated with this request
  127. * `magic`: the ID of the checker result to look up
  128. **Returns:** a dictionary representing the checker's results; this
  129. dictionary will contain the following keys:
  130. * _Input information_:
  131. * `'path'`: a list of string representing the path associated with this
  132. result
  133. * `'username'`: the user who submitted the request
  134. * `'names'`: a list containing the names of the questions that were
  135. submitted
  136. * `'form'`: a dictionary containing the values given in the form (among
  137. them, the values that were submitted)
  138. * `'time'`: a Unix timestamp, when this request was submitted
  139. * `'action'`: either `'check'` or `'submit'`, depending on which button
  140. was clicked to initiate the submission
  141. * _Output information_:
  142. * `'score'`: a `float` between 0 and 1 indicating the score given to
  143. this submission
  144. * `'score_box'`: a string containing the HTML-rendered version of the
  145. score (to be displayed to the user
  146. * `'response'`: the HTML that should be displayed back to the user
  147. about their submission (test results, etc)
  148. * `'extra_data'`: any extra data returned by the checker, or `None` for
  149. question types that don't return extra data
  150. """
  151. with open(
  152. os.path.join(
  153. context["cs_data_root"],
  154. "_logs",
  155. "_checker",
  156. "results",
  157. magic[0],
  158. magic[1],
  159. magic,
  160. ),
  161. "rb",
  162. ) as f:
  163. out = context["csm_cslog"].unprep(f.read())
  164. return out
  165. def compute_page_stats(context, user, path, keys=None):
  166. """
  167. Compute statistics about the given user and page.
  168. This function is designed to provide all the information one could want to
  169. know about a given page, including both information about the page itself
  170. and about the given user's activities on the page.
  171. Exactly what values are computed and included in the result depends on the
  172. value of the optional parameter `keys`. If no value is provided, all of
  173. the following keys are included in the resulting dictionary. Otherwise,
  174. only the keys given by `keys` are included.
  175. Possible keys are:
  176. * `'context'`: an approximation of the context the user would see if they
  177. loaded the page, after the entire page load completes (including the
  178. handler)
  179. * `'questions'`: an `OrderedDict` mapping question names to tuples, in the
  180. same order they are specified on the page. each value is a tuple of
  181. the form outputted by `catsoop.tutor.question`
  182. * `'question_info'`: an ordered dictionary mapping question names to
  183. dictionaries with information about those questions
  184. * `'state'`: the user's most recent "problemstate" log entry for this page
  185. * `'actions'`: a list containing all the user's actions on this page
  186. * `'manual_grades'`: a list containing all manual grading entries for the
  187. user on this page (i.e., all grades assigned _to them_)
  188. If the function is simply used to look up logs, it can be reasonably
  189. efficient. If it is used to inspect the context, it will be a bit slower,
  190. as it must then simulate the entire process associated with the given user
  191. loading the given page.
  192. **Parameters:**
  193. * `context`: the context associated with this request
  194. * `user`: the name of the user whose stats we want to compute
  195. * `path`: a list of strings representing the path of interest
  196. **Optional Parameters:**
  197. * `keys` (default `None`): a list of strings representing the keys of
  198. * interest. if no value is specified, all possible keys are included
  199. **Returns:** a dictionary containing the information detailed above
  200. """
  201. logging = cslog
  202. if keys is None:
  203. keys = ["context", "question_info", "state", "actions", "manual_grades"]
  204. keys = list(keys)
  205. out = {}
  206. if "state" in keys:
  207. keys.remove("state")
  208. out["state"] = logging.most_recent(user, path, "problemstate", {})
  209. if "actions" in keys:
  210. keys.remove("actions")
  211. out["actions"] = logging.read_log(user, path, "problemactions")
  212. if "manual_grades" in keys:
  213. keys.remove("manual_grades")
  214. out["manual_grades"] = logging.read_log(user, path, "problemgrades")
  215. if "question_info" in keys and "context" not in keys:
  216. qi_log = logging.most_recent("_question_info", path, "question_info", None)
  217. if qi_log is not None:
  218. keys.remove("question_info")
  219. out["question_info"] = qi_log["questions"]
  220. if len(keys) == 0:
  221. return out
  222. # spoof loading the page for the user in question
  223. new = dict(context)
  224. loader.load_global_data(new)
  225. new["cs_path_info"] = path
  226. cfile = context["csm_dispatch"].content_file_location(context, new["cs_path_info"])
  227. loader.do_preload(context, path[0], path[1:], new, cfile)
  228. new["cs_course"] = path[0]
  229. new["cs_username"] = user
  230. new["cs_form"] = {"action": "passthrough"}
  231. new["cs_user_info"] = {"username": user}
  232. new["cs_user_info"] = auth.get_user_information(new)
  233. loader.load_content(context, path[0], path[1:], new, cfile)
  234. if "cs_post_load" in new:
  235. new["cs_post_load"](new)
  236. handle_page(new)
  237. if "cs_post_handle" in new:
  238. new["cs_post_handle"](new)
  239. if "context" in keys:
  240. keys.remove("context")
  241. out["context"] = new
  242. if "question_info" in keys:
  243. keys.remove("question_info")
  244. items = new["cs_defaulthandler_name_map"].items()
  245. out["question_info"] = OrderedDict()
  246. for (n, (q, a)) in items:
  247. qi = out["question_info"][n] = {}
  248. qi["csq_name"] = n
  249. qi["csq_npoints"] = q["total_points"](**a)
  250. qi["csq_display_name"] = a.get("csq_display_name", "csq_name")
  251. qi["qtype"] = q["qtype"]
  252. qi["csq_grading_mode"] = a.get("csq_grading_mode", "auto")
  253. for k in keys:
  254. out[k] = None
  255. return out
  256. def qtype_inherit(context, other_type):
  257. """
  258. Helper function for a question type to inherit from another question type.
  259. This loads all values from the given question type into the given context
  260. (typically, the environment associated with a "child" question type).
  261. **Parameters:**
  262. * `context`: the dictionary into which the inherited values should be
  263. placed
  264. * `other_type`: a string containing the name of the question type whose
  265. values should be inherited
  266. **Returns:** `None`
  267. """
  268. base, _ = question(context, other_type)
  269. context.update({k: v for k, v in base.items() if k != "qtype"})
  270. def _wrapped_defaults_maker(context, name):
  271. orig = context[name]
  272. def _wrapped_func(*args, **kwargs):
  273. info = dict(context.get("defaults", {}))
  274. info.update(context["cs_question_type_defaults"].get(context["qtype"], {}))
  275. info.update(kwargs)
  276. return orig(*args, **info)
  277. return _wrapped_func
  278. def question(context, qtype, **kwargs):
  279. """
  280. Generate a data structure representing a question.
  281. Looks for the specified question type in the course level first, and then
  282. in the global location.
  283. This function is called as `tutor.question(qtype, **kwargs)` in almost all
  284. cases; i.e., it is called without the first argument. A hack in
  285. `catsoop.loader.cs_compile` will insert the first argument.
  286. **Parameters:**
  287. * `context`: the context associated with this request
  288. * `qtype`: the name of the requested question type, as a string
  289. **Keyword Arguments:**
  290. * The keyword arguments given to this function represent options for it
  291. (e.g., `csq_soln`). The options that have an effect depend on the
  292. question type. In the case of XML or Markdown input format, all
  293. variables defined in the environment associated with this question are
  294. passed in as keyword arguments.
  295. **Returns:** a tuple containing two dictionaries: the first contains the
  296. variables defined by the question type, and the second contains the
  297. environment associated with the question (including variables defined in
  298. the &lt;question&gt; tag).
  299. """
  300. try:
  301. course = context["cs_course"]
  302. qtypes_folder = os.path.join(
  303. context.get("cs_data_root", base_context.cs_data_root),
  304. "courses",
  305. course,
  306. "__QTYPES__",
  307. )
  308. loc = os.path.join(qtypes_folder, qtype)
  309. fname = os.path.join(loc, "%s.py" % qtype)
  310. assert os.path.isfile(fname)
  311. except:
  312. qtypes_folder = os.path.join(
  313. context.get("cs_fs_root", base_context.cs_fs_root), "__QTYPES__"
  314. )
  315. loc = os.path.join(qtypes_folder, qtype)
  316. fname = os.path.join(loc, "%s.py" % qtype)
  317. new = dict(context)
  318. new["csm_base_context"] = new["base_context"] = base_context
  319. pre_code = (
  320. "import sys\n"
  321. "_orig_path = sys.path\n"
  322. "if %r not in sys.path:\n"
  323. " sys.path = [%r] + sys.path\n\n"
  324. ) % (loc, loc)
  325. new["qtype"] = qtype
  326. x = loader.cs_compile(fname, pre_code=pre_code, post_code="sys.path = _orig_path")
  327. exec(x, new)
  328. for i in {
  329. "total_points",
  330. "handle_submission",
  331. "handle_check",
  332. "render_html",
  333. "answer_display",
  334. }:
  335. if i in new:
  336. new[i] = _wrapped_defaults_maker(new, i)
  337. return (new, kwargs)
  338. def handler(context, handler, check_course=True):
  339. """
  340. Generate a data structure representing an activity.
  341. **Parameters:**
  342. * `context`: the context associated with this request
  343. * `handler`: the name of the requested handler as a string
  344. **Optional Parameters:**
  345. * `check_course` (default `True)`: if `True`, look for the specified
  346. handler in the course level first, and then in the global location;
  347. otherwise, look only in the global location
  348. **Returns:** a dictionary containing the variables defined by the handler
  349. """
  350. new = {}
  351. new["csm_base_context"] = new["base_context"] = base_context
  352. for i in context:
  353. if i.startswith("csm_"):
  354. new[i] = new[i[4:]] = context[i]
  355. try:
  356. assert check_course
  357. course = context["cs_course"]
  358. qtypes_folder = os.path.join(
  359. context.get("cs_data_root", base_context.cs_data_root),
  360. "courses",
  361. course,
  362. "__HANDLERS__",
  363. )
  364. loc = os.path.join(qtypes_folder, handler)
  365. fname = os.path.join(loc, "%s.py" % handler)
  366. assert os.path.isfile(fname)
  367. except:
  368. fname = os.path.join(
  369. context.get("cs_fs_root", base_context.cs_fs_root),
  370. "__HANDLERS__",
  371. handler,
  372. "%s.py" % handler,
  373. )
  374. code = loader.cs_compile(fname)
  375. exec(code, new)
  376. return new
  377. def get_release_date(context):
  378. """
  379. Get the release date of a page from the given context.
  380. The inspected variable is `cs_release_date`. If `cs_release_date` has not
  381. been set, `'ALWAYS'` will be used (1 January 1900 at 00:00).
  382. Additionally, if `cs_realize_time` is defined in the given context, it will
  383. be used in place of `catsoop.time.realize_time` (note that it must have the
  384. same number and type of arguments, and the same return type).
  385. **Parameters:**
  386. * `context`: the context associated with this request
  387. **Returns:** an instance of `datetime.datetime` representing the page's
  388. release date.
  389. """
  390. rel = context.get("cs_release_date", "ALWAYS")
  391. if callable(rel):
  392. rel = rel(context)
  393. realize = context.get("cs_realize_time", time.realize_time)
  394. return realize(context, context.get("cs_release_date", "ALWAYS"))
  395. def get_due_date(context):
  396. """
  397. Get the due date of a page from the given context.
  398. The inspected variable is `cs_due_date`. If `cs_due_date` is not defined,
  399. `'NEVER'` will be used (31 December 9999 at 23:59).
  400. Additionally, if `cs_realize_time` is defined in context, it will be used
  401. in place of `catsoop.time.realize_time`.
  402. **Parameters:**
  403. * `context`: the context associated with this request
  404. **Returns:** an instance of `datetime.datetime` representing the page's due
  405. date.
  406. """
  407. due = context.get("cs_due_date", "NEVER")
  408. if callable(due):
  409. due = due(context)
  410. realize = context.get("cs_realize_time", time.realize_time)
  411. due = realize(context, due)
  412. return due
  413. def available_courses():
  414. """
  415. Returns a list of available courses on the system.
  416. This function loops over directories in the `courses` directory. For each,
  417. it executes its top-level `preload.py`. If `cs_course_available` is `True`
  418. (or not specified), that course is included in the listing.
  419. **Returns:** a list of tuples. Each tuple contains `(shortname,
  420. longname)`, where `shortname` is the name to use in a URL referencing the
  421. course, and `longname` is a more descriptive name (governed by the value of
  422. `cs_long_name` in that course's `preload.py` file).
  423. """
  424. base = os.path.join(base_context.cs_data_root, "courses")
  425. if not os.path.isdir(base):
  426. return []
  427. out = []
  428. for course in os.listdir(base):
  429. if course.startswith("_") or course.startswith("."):
  430. continue
  431. if not os.path.isdir(os.path.join(base, course)):
  432. continue
  433. try:
  434. data = loader.generate_context([course])
  435. except:
  436. out.append((course, None))
  437. continue
  438. if data.get("cs_course_available", True):
  439. t = data.get("cs_long_name", course)
  440. out.append((course, t))
  441. return out
  442. def handle_page(context):
  443. """
  444. Determine and invoke the appropriate handler for a page.
  445. If `cs_handler` is defined in the given context, then the handler with that
  446. name is used. Otherwise, the default handler is used.
  447. Regardless, the given handler's `handle` function is called on the given
  448. context. The overall result of this function depends on that function's
  449. output:
  450. * if `handle` returns a 3-tuple (representing a specific HTTP response to
  451. send), that value is returned directly (and `catsoop.dispatch.main` will
  452. send that response directly).
  453. * otherwise, `cs_content` is replaced with the result
  454. **Parameters:**
  455. * `context`: the context associated with this request (from which
  456. * `cs_handler` is retrieved)
  457. **Returns:** a value based on the result of the chosen handler's `handle`
  458. function (see above).
  459. """
  460. hand = context.get("cs_handler", "default")
  461. h = handler(context, hand)
  462. result = h["handle"](context)
  463. if isinstance(result, tuple):
  464. return result
  465. context["cs_content"] = result
  466. def _new_random_seed(n=100):
  467. try:
  468. return os.urandom(n)
  469. except:
  470. return "".join(random.choice(string.ascii_letters) for i in range(n))
  471. def _get_random_seed(context, n=100, force_new=False):
  472. uname = context["cs_username"]
  473. if force_new:
  474. stored = None
  475. else:
  476. stored = context["csm_cslog"].most_recent(
  477. uname, context["cs_path_info"], "random_seed", None
  478. )
  479. if stored is None:
  480. stored = _new_random_seed(n)
  481. context["csm_cslog"].update_log(
  482. uname, context["cs_path_info"], "random_seed", stored
  483. )
  484. return stored
  485. def init_random(context):
  486. """
  487. Initialize the random number generator for per-user, per-page randomness.
  488. Random seeds are stored in a log. This function will try to read that log
  489. to determine the appropriate random seed for this user and page. If no
  490. such seed exists, a new random value is generated (from `/dev/urandom` if
  491. possible).
  492. This value is then stored as `cs_random_seed` and used to seed the
  493. `random.Random` instance in `cs_random`.
  494. This function is called as `tutor.init_random()` in almost all cases; i.e.,
  495. it is called with no arguments. A hack in `catsoop.loader.cs_compile` will
  496. insert the argument.
  497. **Parameters:**
  498. * `context`: the context associated with this request
  499. **Returns:** `None`
  500. """
  501. try:
  502. seed = _get_random_seed(context)
  503. except:
  504. seed = "___".join([context["cs_username"]] + context["cs_path_info"])
  505. context["cs_random_seed"] = seed
  506. context["cs_random"].seed(seed)
  507. context["cs_random_inited"] = True