potluck_server.app
Flask WSGI web app for managing student submissions & grading them w/ potluck. Fork of Ocean server for Codder. Can withhold feedback until after the deadline, so the display of feedback is detached a bit from the generation of feedback. Also supports exercises, which are graded immediately & submitted programmatically via potluck_delivery (generally set up to deliver when tests are run).
potluck_server/app.py
To run the server, the current directory must contain ps_config.py
or
ps_config.py.example
which will be copied to create ps_config.py
. The
additional secret
and syntauth
files will be created automatically
using os.urandom
if necessary; see rundir
in the package directory
for an example.
Note that the potluck
package must be installed, along with flask
,
jinja2
, and flask_cas
. Additionally, flask_talisman
and/or
flask_seasurf
may be installed, and if present they will be used to
provide additional layers of security. Finally, if pyopenssl
is
installed alongside flask_talisman
, even when running in debug mode
HTTPS and the content-security-policy will be enabled, which helps spot
errors earlier at the cost of having to click through a security warning
from your browser about the self-signed certificate.
Students log in using CAS, select a project, and submit one file per task (where the filename is checked against a configurable list), and can view their most recent submission, see any warnings on it, or even submit a revision. Once the deadline + review period has passed (modulo extensions, or immediately in some cases), the full report from each student's last on-time submission is made available. Main routes:
/ (route_index): Checks for CAS authentication and redirects to login page or to dashboard.
/dashboard (route_default_dash): Auto-redirects to the dashboard for the "current" course/semester based on configuration.
/$course/$semester/dashboard (route_dash): Main interface w/ rows for each project. Each row has sub-rows for each task w/ upload button to submit the task and link to latest feedback for that task. As well as project submission options, the dashboard has a course concepts overview that tracks competencies demonstrated in projects and via exercises. TODO: Concepts overview.
/$course/$semester/feedback/$username/$phase/$project/ $task (route_feedback): Displays latest feedback for a given user/phase/project/task. Pre-deadline this just shows any warnings. Allows resubmission of the task directly from this page.
/$course/$semester/evaluate/$username/$phase/$project/ $task (route_evaluate): Admin-only route that shows feedback for a given user/phase/project/task, and allows the admin to enter a custom note that the student will see, and also to override the grade that will be entered on the gradesheet for that task. Note that grade overrides for revisions are still subject to the normal revision score cap in terms of their contributions to a final grade. Feedback for a non-submitted task may be overridden however.
/$course/$semester/set_eval/$username/$phase/$project/
$task (route_set_evaluation):
Form target for updating grade overrides from route_evaluate
.
/$course/$semester/submit/$username/$project/$task (route_submit): Form target for submissions; accepts valid submissions and redirects back to feedback page.
/$course/$semester/extension/$project (route_extension): Request an extension for a project (automatically grants the default extension; the extension always applies only to the initial deadline, not to revisions; and the extension applies to the whole problem set, not per-task). # TODO: Allow students to revoke requested extensions if they haven't # started using them yet.
/$course/$semester/solution/$project/$task (route_solution): View the solution code for a particular task. Only available once a task is finalized (on a per-student basis to deal with extensions).
/$course/$semester/gradesheet (route_full_gradesheet): An overview of all grades for ALL tasks and projects.
/$course/$semester/gradesheet/$project (route_gradesheet): An overview of all grades for a specific problem set, visible only to admins.
/$course/$semester/deliver (route_deliver):
Delivery endpoint for POST data on exercise outcomes, which are
submitted via the stand-alone potluckDelivery
module. Unlike normal
submissions, we do not run any code from these, and we also don't
verify authorship (TODO: that!).
/$course/$semester/exercise/$user/$exercise (route_exercise):
Shows submitted material & status for an exercise submission for a
particular user received via route_deliver
. Admins can modify
exercise feedback for an individual exercise from this page.
/$course/$semester/ex_override/$user/$exercise
(route_exercise_override):
Form target for overriding exercise grades OR exercise group grades
from route_exercise
.
TODO: Enable per-user dashboard vies without MASQUERADE and then let
exercise group overrides be entered from the dashboard view of a
particular student...
/$course/$semester/ex_gradesheet/$group (route_ex_gradesheet): An overview of all grades for a specific exercise group, visible only to admins. # TODO: Make this faster?
1#!/usr/local/bin/python 2# -*- coding: UTF-8 -*- 3""" 4Flask WSGI web app for managing student submissions & grading them w/ 5potluck. Fork of Ocean server for Codder. Can withhold feedback until 6after the deadline, so the display of feedback is detached a bit from 7the generation of feedback. Also supports exercises, which are graded 8immediately & submitted programmatically via potluck_delivery (generally 9set up to deliver when tests are run). 10 11potluck_server/app.py 12 13To run the server, the current directory must contain `ps_config.py` or 14`ps_config.py.example` which will be copied to create `ps_config.py`. The 15additional `secret` and `syntauth` files will be created automatically 16using `os.urandom` if necessary; see `rundir` in the package directory 17for an example. 18 19Note that the `potluck` package must be installed, along with `flask`, 20`jinja2`, and `flask_cas`. Additionally, `flask_talisman` and/or 21`flask_seasurf` may be installed, and if present they will be used to 22provide additional layers of security. Finally, if `pyopenssl` is 23installed alongside `flask_talisman`, even when running in debug mode 24HTTPS and the content-security-policy will be enabled, which helps spot 25errors earlier at the cost of having to click through a security warning 26from your browser about the self-signed certificate. 27 28Students log in using CAS, select a project, and submit one file per task 29(where the filename is checked against a configurable list), and can view 30their most recent submission, see any warnings on it, or even submit a 31revision. Once the deadline + review period has passed (modulo 32extensions, or immediately in some cases), the full report from each 33student's last on-time submission is made available. Main routes: 34 35/ (route_index): 36 Checks for CAS authentication and redirects to login page or to 37 dashboard. 38 39/dashboard (route_default_dash): 40 Auto-redirects to the dashboard for the "current" course/semester 41 based on configuration. 42 43/**$course**/**$semester**/dashboard (route_dash): 44 Main interface w/ rows for each project. Each row has sub-rows for 45 each task w/ upload button to submit the task and link to latest 46 feedback for that task. As well as project submission options, the 47 dashboard has a course concepts overview that tracks competencies 48 demonstrated in projects and via exercises. TODO: Concepts overview. 49 50/**$course**/**$semester**/feedback/**$username**/**$phase**/**$project**/ 51 **$task** 52(route_feedback): 53 Displays latest feedback for a given user/phase/project/task. 54 Pre-deadline this just shows any warnings. 55 Allows resubmission of the task directly from this page. 56 57/**$course**/**$semester**/evaluate/**$username**/**$phase**/**$project**/ 58 **$task** (route_evaluate): 59 Admin-only route that shows feedback for a given 60 user/phase/project/task, and allows the admin to enter a custom note 61 that the student will see, and also to override the grade that will 62 be entered on the gradesheet for that task. Note that grade overrides 63 for revisions are still subject to the normal revision score cap in 64 terms of their contributions to a final grade. Feedback for a 65 non-submitted task may be overridden however. 66 67/**$course**/**$semester**/set_eval/**$username**/**$phase**/**$project**/ 68 **$task** (route_set_evaluation): 69 Form target for updating grade overrides from `route_evaluate`. 70 71/**$course**/**$semester**/submit/**$username**/**$project**/**$task** 72(route_submit): 73 Form target for submissions; accepts valid submissions and redirects 74 back to feedback page. 75 76/**$course**/**$semester**/extension/**$project** (route_extension): 77 Request an extension for a project (automatically grants the default 78 extension; the extension always applies only to the initial deadline, 79 not to revisions; and the extension applies to the whole problem set, 80 not per-task). 81 # TODO: Allow students to revoke requested extensions if they haven't 82 # started using them yet. 83 84/**$course**/**$semester**/solution/**$project**/**$task** (route_solution): 85 View the solution code for a particular task. Only available once a 86 task is finalized (on a per-student basis to deal with extensions). 87 88/**$course**/**$semester**/gradesheet (route_full_gradesheet): 89 An overview of all grades for ALL tasks and projects. 90 91/**$course**/**$semester**/gradesheet/**$project** (route_gradesheet): 92 An overview of all grades for a specific problem set, visible only to 93 admins. 94 95/**$course**/**$semester**/deliver (route_deliver): 96 Delivery endpoint for POST data on exercise outcomes, which are 97 submitted via the stand-alone `potluckDelivery` module. Unlike normal 98 submissions, we do not run any code from these, and we also don't 99 verify authorship (TODO: that!). 100 101/**$course**/**$semester**/exercise/**$user**/**$exercise** (route_exercise): 102 Shows submitted material & status for an exercise submission for a 103 particular user received via `route_deliver`. Admins can modify 104 exercise feedback for an individual exercise from this page. 105 106/**$course**/**$semester**/ex_override/**$user**/**$exercise** 107 (route_exercise_override): 108 Form target for overriding exercise grades OR exercise group grades 109 from `route_exercise`. 110 111 TODO: Enable per-user dashboard vies without MASQUERADE and then let 112 exercise group overrides be entered from the dashboard view of a 113 particular student... 114 115/**$course**/**$semester**/ex_gradesheet/**$group** (route_ex_gradesheet): 116 An overview of all grades for a specific exercise group, visible only to 117 admins. 118 # TODO: Make this faster? 119""" 120 121# Attempts at 2/3 dual compatibility: 122from __future__ import print_function 123 124__version__ = "1.2.18" 125 126import sys 127 128 129# IF we're not in pyhton3: 130if sys.version_info[0] < 3: 131 reload(sys) # noqa F821 132 # Set explicit default encoding 133 sys.setdefaultencoding('utf-8') 134 # Rename raw_input 135 input = raw_input # noqa F821 136 ModuleNotFound_or_Import_Error = ImportError 137 anystr = (str, unicode) # noqa F821 138else: 139 ModuleNotFound_or_Import_Error = ModuleNotFoundError 140 anystr = (str, bytes) 141 142 143def ensure_directory(target): 144 """ 145 makedirs 2/3 shim. 146 """ 147 if sys.version_info[0] < 3: 148 try: 149 os.makedirs(target) 150 except OSError: 151 pass 152 else: 153 os.makedirs(target, exist_ok=True) 154 155 156# Main imports 157import os, subprocess, shutil # noqa E402 158import datetime, time, random, copy, csv, re # noqa E402 159import zipfile # noqa E402 160 161import flask # noqa E402 162import flask_cas # noqa E402 163import jinja2 # noqa E402 164import bs4 # noqa E402 165import werkzeug # noqa E402 166 167from flask import json # noqa E402 168 169from . import storage # noqa E402 170 171# Load potluck modules: 172import potluck.render # noqa: E402 173import potluck.html_tools # noqa: E402 174import potluck.time_utils # noqa: E402 175 176 177# Look for safe_join both in flask and in werkzeug... 178if hasattr(flask, "safe_join"): 179 safe_join = flask.safe_join 180elif hasattr(werkzeug.utils, "safe_join"): 181 safe_join = werkzeug.utils.safe_join 182else: 183 print( 184 "Warning: safe_join was not found in either flask OR" 185 " werkzeug.utils; using an unsafe function instead." 186 ) 187 safe_join = lambda *args: os.path.join(*args) 188 189 190#-------------# 191# Setup setup # 192#-------------# 193 194class InitApp(flask.Flask): 195 """ 196 A Flask app subclass which runs initialization functions right 197 before app startup. 198 """ 199 def __init__(self, *args, **kwargs): 200 """ 201 Arguments are passed through to flask.Flask.__init__. 202 """ 203 self.actions = [] 204 # Note: we don't use super() here since 2/3 compatibility is 205 # too hard to figure out 206 flask.Flask.__init__(self, *args, **kwargs) 207 208 def init(self, action): 209 """ 210 Registers an init action (a function which will be given the app 211 object as its only argument). These functions will each be 212 called, in order of registration, right before the app starts, 213 withe the app_context active. Returns the action function 214 provided, so that it can be used as a decorator, like so: 215 216 ```py 217 @app.init 218 def setup_some_stuff(app): 219 ... 220 ``` 221 """ 222 self.actions.append(action) 223 return action 224 225 def setup(self): 226 """ 227 Runs all registered initialization actions. If you're not running 228 a debugging setup via the `run` method, you'll need to call this 229 method yourself (e.g., in a .wsgi file). 230 """ 231 with self.app_context(): 232 for action in self.actions: 233 action(self) 234 235 def run(self, *args, **kwargs): 236 """ 237 Overridden run method runs our custom init actions with the app 238 context first. 239 """ 240 self.setup() 241 # Note: we don't use super() here since 2/3 compatibility is 242 # too hard to figure out 243 flask.Flask.run(self, *args, **kwargs) 244 245 246#-------# 247# Setup # 248#-------# 249 250# Create our app object: 251app = InitApp("potluck_server") 252 253 254# Default configuration values if we really can't find a config file 255DEFAULT_CONFIG = { 256 "EVALUATION_BASE": '.', 257 "POTLUCK_EVAL_PYTHON": None, 258 "POTLUCK_EVAL_SCRIPT": None, 259 "POTLUCK_EVAL_IMPORT_FROM": None, 260 "DEFAULT_COURSE": 'test_course', 261 "DEFAULT_SEMESTER": 'fall2021', 262 "SUPPORT_EMAIL": 'username@example.com', 263 "SUPPORT_LINK": '<a href="mailto:username@example.com">User Name</a>', 264 "NO_DEBUG_SSL": False, 265 "CAS_SERVER": 'https://login.example.com:443', 266 "CAS_AFTER_LOGIN": 'dashboard', 267 "CAS_LOGIN_ROUTE": '/module.php/casserver/cas.php/login', 268 "CAS_LOGOUT_ROUTE": '/module.php/casserver/cas.php/logout', 269 "CAS_AFTER_LOGOUT": 'https://example.com/potluck', 270 "CAS_VALIDATE_ROUTE": '/module.php/casserver/serviceValidate.php', 271 "DEFAULT_PROJECT_URL_FORMAT": ( 272 'https://example.com/archive/test_course_{semester}/' 273 'public_html/projects/{project}' 274 ), 275 "DEFAULT_TASK_URL_FORMAT": ( 276 'https://example.com/archive/test_course_{semester}/' 277 'public_html/project/{project}/{task}' 278 ), 279 "DEFAULT_EXERCISE_URL_FORMAT": ( 280 'https://example.com/archive/test_course_{semester}/' 281 'public_html/lectures/{group}' 282 ), 283 "TASK_INFO_FILE": 'tasks.json', 284 "CONCEPTS_FILE": 'pl_concepts.json', 285 "ADMIN_INFO_FILE": 'potluck-admin.json', 286 "ROSTER_FILE": 'roster.csv', 287 "STUDENT_INFO_FILE": 'student-info.tsv', 288 "SYNC_PORT": 51723, 289 "FINAL_EVAL_TIMEOUT": 60, 290 "USE_XVFB": False, 291 "XVFB_SERVER_ARGS": "-screen 0 1024x768x24", 292 "REMAP_STUDENT_INFO": {}, 293 "SCORE_BASIS": 100, 294 "ROUND_SCORES_TO": 0, 295 "EVALUATION_SCORES": { 296 "excellent": 100, 297 "complete": 100, 298 "almost complete": 85, 299 "partially complete": 75, 300 "incomplete": 0, 301 "__other__": None 302 }, 303 "REVISION_MAX_SCORE": 100, 304 "BELATED_MAX_SCORE": 85, 305 "TIMELINESS_POINTS": 10, 306 "TIMELINESS_ATTEMPT_THRESHOLD": 75, 307 "TIMELINESS_COMPLETE_THRESHOLD": 85, 308 "TASK_WEIGHT": 2, 309 "EXERCISE_WEIGHT": 1, 310 "OUTCOME_WEIGHT": 0.25, 311 "EXERCISE_GROUP_THRESHOLD": 0.895, 312 "EXERCISE_GROUP_PARTIAL_THRESHOLD": 0.495, 313 "EXERCISE_GROUP_CREDIT_BUMP": 0.0, 314 "LATE_EXERCISE_CREDIT_FRACTION": 0.5, 315 "GRADE_THRESHOLDS": { 316 "low": 75, 317 "mid": 90 318 } 319} 320 321# Loads configuration from file `ps_config.py`. If that file isn't 322# present but `ps_config.py.example` is, copies the latter as the former 323# first. Note that this CANNOT be run as an initialization pass, since 324# the route definitions below require a working configuration. 325try: 326 sys.path.append('.') 327 app.config.from_object('ps_config') 328except Exception as e: # try copying the .example file? 329 if os.path.exists('ps_config.py.example'): 330 print("Creating new 'ps_config.py' from 'ps_config.py.example'.") 331 shutil.copyfile('ps_config.py.example', 'ps_config.py') 332 app.config.from_object('ps_config') 333 else: 334 print( 335 "Neither 'ps_config.py' nor 'ps_config.py.example' is" 336 " available, or there was an error in parsing them, so" 337 " default configuration will be used." 338 ) 339 print("CWD is:", os.getcwd()) 340 print("Error loading 'ps_config.py' was:", str(e)) 341 app.config.from_mapping(DEFAULT_CONFIG) 342finally: 343 sys.path.pop() # no longer need '.' on path... 344 345 346class NotFound(): 347 """ 348 Placeholder class for fallback config values, since None might be a 349 valid value. 350 """ 351 pass 352 353 354def fallback_config_value(key_or_keys, *look_in): 355 """ 356 Gets a config value from the first dictionary it's defined in, 357 falling back to additional dictionaries if the value isn't found. 358 When the key is specified as a list or tuple of strings, repeated 359 dictionary lookups will be used to retrieve a value. 360 361 Returns the special class `NotFound` as a default value if none of 362 the provided dictionaries have a match for the key or key-path 363 requested. 364 """ 365 for conf_dict in look_in: 366 if isinstance(key_or_keys, (list, tuple)): 367 target = conf_dict 368 for key in key_or_keys: 369 if not isinstance(target, dict): 370 break 371 try: 372 target = target[key] 373 except KeyError: 374 break 375 else: 376 return target 377 elif key_or_keys in conf_dict: 378 return conf_dict[key_or_keys] 379 # else continue the loop 380 return NotFound 381 382 383def usual_config_value( 384 key_or_keys, 385 taskinfo, 386 task=None, 387 project=None, 388 exercise=None, 389 default=NotFound 390): 391 """ 392 Runs `fallback_config_value` with a task or exercise dictionary, 393 then the specified task info dictionary, then the app.config values, 394 and then the DEFAULT_CONFIG values. 395 396 `task` will take priority over `project`, which takes priority over 397 `exercise`; in general only one should be specified; each must be an 398 ID string (for a task, a project, or an exercise group). 399 400 If provided, the given `default` value will be returned instead of 401 `NotFound` if the result would otherwise be `NotFound`. 402 """ 403 if task is not None: 404 result = fallback_config_value( 405 key_or_keys, 406 taskinfo.get("tasks", {}).get(task, {}), 407 taskinfo, 408 app.config, 409 DEFAULT_CONFIG 410 ) 411 412 elif project is not None: 413 projects = taskinfo.get( 414 "projects", 415 taskinfo.get("psets", {}) 416 ) 417 matching = [p for p in projects if p["id"] == project] 418 if len(matching) > 0: 419 first = matching[0] 420 else: 421 first = {} 422 result = fallback_config_value( 423 key_or_keys, 424 first, 425 taskinfo, 426 app.config, 427 DEFAULT_CONFIG 428 ) 429 430 elif exercise is not None: 431 exgroups = taskinfo.get("exercises", []) 432 matching = [ 433 g 434 for g in exgroups 435 if g.get("group", None) == exercise 436 ] 437 if len(matching) > 0: 438 first = matching[0] 439 else: 440 first = {} 441 result = fallback_config_value( 442 key_or_keys, 443 first, 444 taskinfo, 445 app.config, 446 DEFAULT_CONFIG 447 ) 448 else: 449 result = fallback_config_value( 450 key_or_keys, 451 taskinfo, 452 app.config, 453 DEFAULT_CONFIG 454 ) 455 456 return default if result is NotFound else result 457 458 459@app.init 460def setup_jinja_loader(app): 461 """ 462 Set up templating with a custom loader that loads templates from the 463 potluck package if it can't find them in this package. This is how 464 we share the report template between potluck_server and potluck. 465 """ 466 app.jinja_loader = jinja2.ChoiceLoader([ 467 jinja2.PackageLoader("potluck_server", "templates"), 468 jinja2.PackageLoader("potluck", "templates") 469 ]) 470 471 472@app.init 473def enable_CAS(app): 474 """ 475 Enable authentication via a Central Authentication Server. 476 """ 477 global cas 478 cas = flask_cas.CAS(app) 479 480 481@app.init 482def setup_potluck_reporting(app): 483 """ 484 Setup for the potluck.render module (uses defaults). 485 """ 486 potluck.render.setup() 487 488 489@app.init 490def create_secret_key(app): 491 """ 492 Set secret key from secret file, or create a new secret key and 493 write it into the secret file. 494 """ 495 if os.path.exists("secret"): 496 with open("secret", 'rb') as fin: 497 app.secret_key = fin.read() 498 else: 499 print("Creating new secret key file 'secret'.") 500 app.secret_key = os.urandom(16) 501 with open("secret", 'wb') as fout: 502 fout.write(app.secret_key) 503 504 505@app.init 506def initialize_storage_module(app): 507 """ 508 Initialize file access and storage system. 509 """ 510 storage.init(app.config) 511 512 513@app.init 514def ensure_required_folders(app): 515 """ 516 Ensures required folders for the default course/semester. 517 TODO: What about non-default courses/semesters? If they're fine, is 518 this even necessary? 519 """ 520 this_course = app.config.get("DEFAULT_COURSE", 'unknown') 521 this_semester = app.config.get("DEFAULT_SEMESTER", 'unknown') 522 storage.ensure_directory( 523 storage.evaluation_directory(this_course, this_semester) 524 ) 525 storage.ensure_directory( 526 storage.submissions_folder(this_course, this_semester) 527 ) 528 529 530#----------------# 531# Security setup # 532#----------------# 533 534NOAUTH = False 535USE_TALISMAN = True 536if __name__ == "__main__": 537 # Print intro here... 538 print("This is potluck_server version {}".format(__version__)) 539 540 print("WARNING: Running in debug mode WITHOUT AUTHENTICATION!") 541 input("Press enter to continue in debug mode.") 542 # Disable login_required 543 flask_cas.login_required = lambda f: f 544 # Set up username workaround 545 NOAUTH = True 546 547 # If OpenSSL is available, we can use talisman even if running in 548 # local debugging mode; otherwise we need to disable talisman. 549 try: 550 import OpenSSL 551 except ModuleNotFound_or_Import_Error: 552 USE_TALISMAN = False 553 554 # Config may disable talisman 555 if app.config.get('NO_DEBUG_SSL'): 556 USE_TALISMAN = False 557 558 559@app.init 560def enable_talisman(app): 561 """ 562 Enable talisman forced-HTTPS and other security headers if 563 `flask_talisman` is available and it won't interfere with debugging. 564 """ 565 talisman_enabled = False 566 if USE_TALISMAN: 567 try: 568 import flask_talisman 569 # Content-security policy settings 570 csp = { 571 'default-src': "'self'", 572 'script-src': "'self' 'report-sample'", 573 'style-src': "'self'", 574 'img-src': "'self' data:" 575 } 576 flask_talisman.Talisman( 577 app, 578 content_security_policy=csp, 579 content_security_policy_nonce_in=[ 580 'script-src', 581 'style-src' 582 ] 583 ) 584 talisman_enabled = True 585 except ModuleNotFound_or_Import_Error: 586 print( 587 "Warning: module flask_talisman is not available;" 588 " security headers will not be set." 589 ) 590 591 if talisman_enabled: 592 print("Talisman is enabled.") 593 else: 594 print("Talisman is NOT enabled.") 595 # Add csp_nonce global dummy since flask_talisman didn't 596 app.add_template_global( 597 lambda: "-nonce-disabled-", 598 name='csp_nonce' 599 ) 600 601 602@app.init 603def setup_seasurf(app): 604 """ 605 Sets up `flask_seasurf` to combat cross-site request forgery, if 606 that module is available. 607 608 Note that `route_deliver` is exempt from CSRF verification. 609 """ 610 global route_deliver 611 try: 612 import flask_seasurf 613 csrf = flask_seasurf.SeaSurf(app) # noqa F841 614 route_deliver = csrf.exempt(route_deliver) 615 except ModuleNotFound_or_Import_Error: 616 print( 617 "Warning: module flask_seasurf is not available; CSRF" 618 " protection will not be enabled." 619 ) 620 # Add csrf_token global dummy since flask_seasurf isn't available 621 app.add_template_global(lambda: "-disabled-", name='csrf_token') 622 623 624#---------# 625# Helpers # 626#---------# 627 628def augment_arguments(route_function): 629 """ 630 A decorator that modifies a route function to supply `username`, 631 `is_admin`, `masquerade_as`, `effective_user`, and `task_info` 632 keyword arguments along with the other arguments the route receives. 633 Must be applied before app.route, and the first two parameters to the 634 function must be the course and semester. 635 636 Because flask/werkzeug routing requires function signatures to be 637 preserved, we do some dirty work with compile and eval... As a 638 result, this function can only safely be used to decorate functions 639 that don't have any keyword arguments. Furthermore, the 5 augmented 640 arguments must be the last 5 arguments that the function accepts. 641 """ 642 def with_extra_arguments(*args, **kwargs): 643 """ 644 A decorated route function which will be supplied with username, 645 is_admin, masquerade_as, effective_user, and task_info parameters 646 as keyword arguments after other arguments have been supplied. 647 """ 648 # Get username 649 if NOAUTH: 650 username = "test" 651 else: 652 username = cas.username 653 654 # Grab course + semester values 655 course = kwargs.get('course', args[0] if len(args) > 0 else None) 656 semester = kwargs.get( 657 'semester', 658 args[1 if 'course' not in kwargs else 0] 659 if len(args) > 0 else None 660 ) 661 662 if course is None or semester is None: 663 flask.flash( 664 ( 665 "Error: Unable to get course and/or semester. Course" 666 " is {} and semester is {}." 667 ).format(repr(course), repr(semester)) 668 ) 669 return error_response( 670 course, 671 semester, 672 username, 673 ( 674 "Failed to access <code>course</code> and/or" 675 " <code>semester</code> values." 676 ) 677 ) 678 679 # Get admin info 680 admin_info = storage.get_admin_info(course, semester) 681 if admin_info is None: 682 flask.flash("Error loading admin info!") 683 admin_info = {} 684 685 # Check user privileges 686 is_admin, masquerade_as = check_user_privileges(admin_info, username) 687 688 # Effective username 689 effective_user = masquerade_as or username 690 691 # Get basic info on all projects/tasks 692 task_info = storage.get_task_info(course, semester) 693 if task_info is None: # error loading task info 694 flask.flash("Error loading task info!") 695 return error_response( 696 course, 697 semester, 698 username, 699 "Failed to load <code>tasks.json</code>." 700 ) 701 702 # Set pause time for the task info 703 set_pause_time(admin_info, task_info, username, masquerade_as) 704 705 # Update the kwargs 706 kwargs["username"] = username 707 kwargs["is_admin"] = is_admin 708 kwargs["masquerade_as"] = masquerade_as 709 kwargs["effective_user"] = effective_user 710 kwargs["task_info"] = task_info 711 712 # Call the decorated function w/ the extra parameters we've 713 # deduced. 714 return route_function(*args, **kwargs) 715 716 # Grab info on original function signature 717 fname = route_function.__name__ 718 nargs = route_function.__code__.co_argcount 719 argnames = route_function.__code__.co_varnames[:nargs - 5] 720 721 # Create a function with the same signature: 722 code = """\ 723def {name}({args}): 724 return with_extra_arguments({args}) 725""".format(name=fname, args=', '.join(argnames)) 726 env = {"with_extra_arguments": with_extra_arguments} 727 # 2/3 compatibility attempt... 728 if sys.version_info[0] < 3: 729 exec(code) in env, env 730 else: 731 exec(code, env, env) in env, env 732 result = env[fname] 733 734 # Preserve docstring 735 result.__doc__ = route_function.__doc__ 736 737 # Return our synthetic function... 738 return result 739 740 741def goback(course, semester): 742 """ 743 Returns a flask redirect aimed at either the page that the user came 744 from, or the dashboard if that information isn't available. 745 """ 746 if flask.request.referrer: 747 # If we know where you came from, send you back there 748 return flask.redirect(flask.request.referrer) 749 else: 750 # Otherwise back to the dashboard 751 return flask.redirect( 752 flask.url_for('route_dash', course=course, semester=semester) 753 ) 754 755 756#-----------------# 757# Route functions # 758#-----------------# 759 760@app.route('/') 761def route_index(): 762 """ 763 Checks authentication and redirects to login page or to dashboard. 764 """ 765 if NOAUTH or cas.username: 766 return flask.redirect(flask.url_for('route_default_dash')) 767 else: 768 return flask.redirect(flask.url_for('cas.login')) 769 770 771@app.route('/dashboard') 772@flask_cas.login_required 773def route_default_dash(): 774 """ 775 Redirects to dashboard w/ default class/semester. 776 """ 777 return flask.redirect( 778 flask.url_for( 779 'route_dash', 780 course=app.config.get("DEFAULT_COURSE", "unknown"), 781 semester=app.config.get("DEFAULT_SEMESTER", "unknown") 782 ) 783 ) 784 785 786@app.route('/<course>/<semester>/dashboard') 787@flask_cas.login_required 788@augment_arguments 789def route_dash( 790 course, 791 semester, 792 # Augmented arguments 793 username, 794 is_admin, 795 masquerade_as, 796 effective_user, 797 task_info 798): 799 """ 800 Displays dashboard w/ links for submitting each project/task & summary 801 information of task grades. Also includes info on submitted 802 exercises. 803 """ 804 805 # Add project status and task feedback summaries to task info 806 amend_task_info(course, semester, task_info, effective_user) 807 808 # Get concepts info 809 concepts = storage.get_concepts(course, semester) 810 if concepts is None: 811 flask.flash("Warning: concepts not specified/available.") 812 concepts = [] 813 814 # Grab latest-outcomes info for ALL exercises 815 outcomes = fetch_all_best_outcomes( 816 course, 817 semester, 818 effective_user, 819 task_info 820 ) 821 822 # Amend exercise statuses 823 amend_exercises(course, semester, task_info, outcomes, effective_user) 824 825 # Augment the concepts structure to include real object 826 # references 827 augment_concepts(concepts) 828 829 # Update concepts list with statuses based on exercise outcomes 830 # and amended task info. 831 set_concept_statuses(concepts, task_info, outcomes) 832 833 # Render dashboard template 834 return flask.render_template( 835 'dashboard.j2', 836 course_name=task_info.get("course_name", course), 837 course=course, 838 semester=semester, 839 username=username, 840 is_admin=is_admin, 841 masquerade_as=masquerade_as, 842 effective_user=effective_user, 843 task_info=task_info, 844 timeliness_matters=( 845 0 < usual_config_value("TIMELINESS_POINTS", task_info, default=0) 846 ), 847 outcomes=outcomes, # TODO: USE THIS 848 concepts=concepts # TODO: USE THIS 849 ) 850 851 852@app.route( 853 '/<course>/<semester>/feedback/<target_user>/<phase>/<prid>/<taskid>' 854) 855@flask_cas.login_required 856@augment_arguments 857def route_feedback( 858 course, 859 semester, 860 target_user, 861 phase, 862 prid, 863 taskid, 864 # Augmented arguments 865 username, 866 is_admin, 867 masquerade_as, 868 effective_user, 869 task_info 870): 871 """ 872 Displays feedback on a particular task of a particular problem set, 873 for either the 'initial' or 'revision' phase. 874 """ 875 if target_user != effective_user and not is_admin: 876 return error_response( 877 course, 878 semester, 879 username, 880 "You are not allowed to view feedback for {}.".format(target_user) 881 ) 882 elif target_user != effective_user: 883 flask.flash("Viewing feedback for {}.".format(target_user)) 884 885 # From here on we treat the effective user as the target user 886 effective_user = target_user 887 888 # Check roster and flash a warning if we're viewing feedback for a 889 # user who is not on the roster... 890 try: 891 roster = storage.get_roster(course, semester) 892 except Exception as e: 893 flask.flash(str(e)) 894 roster = None 895 896 if roster is None: 897 flask.flash( 898 "Warning: could not fetch roster to check if this user is on" 899 " it." 900 ) 901 elif effective_user not in roster: 902 flask.flash("Warning: this user is not on the roster!") 903 904 # Get full feedback-ready project and task objects 905 pr_and_task = get_feedback_pr_and_task( 906 task_info, 907 course, 908 semester, 909 target_user, 910 phase, 911 prid, 912 taskid 913 ) 914 if isinstance(pr_and_task, ValueError): 915 flask.flash(str(pr_and_task)) 916 return goback(course, semester) 917 else: 918 pr, task = pr_and_task 919 920 if task["eval_status"] not in ( 921 None, 922 "unknown", 923 "initial", 924 "in_progress", 925 "error", 926 "expired", 927 "completed" 928 ): 929 msg = "Invalid evaluation status <code>{}</code>.".format( 930 task["eval_status"] 931 ) 932 flask.flash(msg) 933 return error_response(course, semester, username, msg) 934 935 # Set auto-refresh based on evaluation status 936 if task["eval_status"] in ("unknown", "in_progress", "error"): 937 refresh = 20 938 flask.flash("""\ 939This page should refresh itself {refresh} seconds after loading 940<span 941 role="timer" 942 aria-live="off" 943 class="timer" 944 data-time="{refresh}" 945> 946 {refresh} 947</span>.""".format(refresh=refresh)) 948 else: 949 refresh = None 950 951 return flask.render_template( 952 'feedback.j2', 953 course_name=task_info.get("course_name", course), 954 course=course, 955 semester=semester, 956 username=username, 957 is_admin=is_admin, 958 masquerade_as=masquerade_as, 959 effective_user=effective_user, 960 target_user=target_user, 961 phase=phase, 962 pr=pr, 963 task=task, 964 task_info=task_info, 965 fb_css=potluck.render.get_css(), 966 fb_js=potluck.render.get_js(), 967 score_basis=usual_config_value( 968 "SCORE_BASIS", 969 task_info, 970 task=taskid, 971 default=0 972 ), 973 support_link=usual_config_value( 974 "SUPPORT_LINK", 975 task_info, 976 task=taskid 977 ), 978 refresh=refresh 979 ) 980 981 982@app.route( 983 '/<course>/<semester>/submit/<prid>/<taskid>', 984 methods=['POST'] 985) 986@flask_cas.login_required 987@augment_arguments 988def route_submit( 989 course, 990 semester, 991 prid, 992 taskid, 993 # Augmented arguments 994 username, 995 is_admin, 996 masquerade_as, 997 effective_user, 998 task_info 999): 1000 """ 1001 Accepts a file submission for a task and initiates an evaluation 1002 process for that file. Figures out submission phase automatically 1003 based on task info, and assumes that the submission belongs to the 1004 authenticated user. However, if the authenticated user is an admin, 1005 the "phase" and "target_user" form values can override these 1006 assumptions. Redirects to the feedback page for the submitted task, 1007 or to the evaluation page if the user is an admin and is not the 1008 target user. 1009 1010 Note: there are probably some nasty race conditions if the same user 1011 submits the same task simultaneously via multiple requests. We simply 1012 hope that that does not happen. 1013 """ 1014 try: 1015 pr = get_pr_obj(task_info, prid) 1016 except ValueError as e: 1017 flask.flash(str(e)) 1018 return goback(course, semester) 1019 1020 try: 1021 task = get_task_obj(task_info, pr, taskid) 1022 except ValueError as e: 1023 flask.flash(str(e)) 1024 return goback(course, semester) 1025 1026 # Add status & time remaining info to project 1027 amend_project(course, semester, task_info, pr, effective_user) 1028 1029 # Check for phase and/or target_user overrides in the form data if 1030 # the authenticated user is an admin 1031 target_user = effective_user 1032 phase = None 1033 destination_route = 'route_feedback' 1034 if is_admin: 1035 target_from_form = flask.request.form.get("target_user") 1036 if target_from_form not in ("", "auto"): 1037 target_user = target_from_form 1038 phase_from_form = flask.request.form.get("phase") 1039 if phase_from_form != "auto": 1040 phase = phase_from_form 1041 1042 # Send admins to 'route_evaluate' instead of 'route_feedback' if 1043 # the target user is not the same as the actual user, whether 1044 # because of a masquerade or because of an explicit target user. 1045 if target_user != username: 1046 destination_route = 'route_evaluate' 1047 1048 # Determine the phase from the project state if we need to 1049 if phase is None: 1050 pr_state = pr['status']['state'] 1051 if pr_state == "unknown": 1052 msg = "{} has no deadline.".format(prid) 1053 flask.flash(msg) 1054 return error_response(course, semester, username, msg) 1055 elif pr_state in ("unreleased", "released"): 1056 phase = "initial" 1057 elif pr_state in ("under_review", "revisable"): 1058 phase = "revision" 1059 #if pr_state == "under_review": 1060 # flask.flash( 1061 # "You should probably wait until the review period is" 1062 # + " over and view feedback on your initial submission" 1063 # + " before submitting a revision." 1064 # ) 1065 elif pr_state == "final": 1066 flask.flash( 1067 "We are no longer accepting new submissions for {}.".format( 1068 prid 1069 ) 1070 ) 1071 phase = "belated" 1072 1073 # Ensure that there's a file being submitted 1074 files = flask.request.files 1075 if ('upload' not in files or files['upload'].filename == ''): 1076 flask.flash("You must choose a file to submit.") 1077 return goback(course, semester) 1078 else: 1079 uploaded = files['upload'] 1080 1081 # Check for an in-flight grading process for this task 1082 ts, _, _, status = storage.get_inflight( 1083 course, 1084 semester, 1085 effective_user, 1086 phase, 1087 prid, 1088 taskid 1089 ) 1090 1091 # If the submission is already being evaluated, or if we couldn't 1092 # figure out whether that was the case, we can't start another 1093 # evaluation process! 1094 if ts == "error": 1095 flask.flash( 1096 ( 1097 "ERROR: Failed to check evaluation status. Try" 1098 + " refreshing this page, and if the problem persists," 1099 + " contact {}." 1100 ).format( 1101 usual_config_value( 1102 "SUPPORT_LINK", 1103 task_info, 1104 task=taskid 1105 ) 1106 ) 1107 ) 1108 return flask.redirect( 1109 flask.url_for( 1110 destination_route, 1111 course=course, 1112 semester=semester, 1113 target_user=effective_user, 1114 phase=phase, 1115 prid=prid, 1116 taskid=taskid 1117 ) 1118 ) 1119 elif status in ("initial", "in_progress"): 1120 flask.flash( 1121 ( 1122 "ERROR: Task {taskid} for project {prid} is" 1123 + " currently being evaluated. You must wait until" 1124 + " that process is complete before uploading a" 1125 + "revised submission. This should take no longer" 1126 + "than {timeout} seconds." 1127 ).format( 1128 taskid=taskid, 1129 prid=prid, 1130 timeout=usual_config_value( 1131 'FINAL_EVAL_TIMEOUT', 1132 task_info, 1133 task=taskid 1134 ) 1135 ) 1136 ) 1137 return flask.redirect( 1138 flask.url_for( 1139 destination_route, 1140 course=course, 1141 semester=semester, 1142 target_user=effective_user, 1143 phase=phase, 1144 prid=prid, 1145 taskid=taskid 1146 ) 1147 ) 1148 # else we assume the status is some final status or None meaning 1149 # this is the first submission 1150 1151 # Record the time spent value 1152 if ( 1153 'time_spent' not in flask.request.form 1154 or flask.request.form["time_spent"] == "" 1155 ): 1156 flask.flash( 1157 "You did not give us an estimate of how much time this" 1158 + " task took you. Please re-submit and enter a time spent" 1159 + " value so that we can help advise future students about" 1160 + " how long this task will take them." 1161 ) 1162 time_spent = "" 1163 else: 1164 time_spent = flask.request.form["time_spent"] 1165 1166 storage.record_time_spent( 1167 course, 1168 semester, 1169 target_user, 1170 phase, 1171 prid, 1172 taskid, 1173 time_spent 1174 ) 1175 1176 # Save the file with the correct filename, ignoring the name that 1177 # the user uploaded. 1178 target = get_submission_filename( 1179 course, 1180 semester, 1181 task_info, 1182 target_user, 1183 phase, 1184 prid, 1185 taskid 1186 ) 1187 destdir, _ = os.path.split(target) 1188 storage.ensure_directory(destdir) 1189 storage.make_way_for(target) 1190 uploaded.save(target) 1191 # TODO: Flash categories 1192 if phase == "belated": 1193 flask.flash( 1194 ( 1195 "Uploaded LATE '{filename}' for {prid} {taskid}." 1196 ).format( 1197 filename=uploaded.filename, 1198 prid=prid, 1199 taskid=taskid 1200 ) 1201 ) 1202 else: 1203 flask.flash( 1204 ( 1205 "Successfully uploaded {phase} submission" 1206 + " '{filename}' for {prid} {taskid}." 1207 ).format( 1208 phase=phase, 1209 filename=uploaded.filename, 1210 prid=prid, 1211 taskid=taskid 1212 ) 1213 ) 1214 1215 # Flash a warning if the uploaded filename seems wrong 1216 if uploaded.filename != task["target"]: 1217 flask.flash( 1218 ( 1219 "Warning: you uploaded a file named '{filename}'," 1220 + " but {taskid} in {prid} requires a file named" 1221 + " '{reqname}'. Are you sure you uploaded the" 1222 + " correct file?" 1223 ).format( 1224 filename=uploaded.filename, 1225 prid=prid, 1226 taskid=taskid, 1227 reqname=task["target"] 1228 ) 1229 ) 1230 1231 # Log setup 1232 ts, logfile, reportfile, _ = storage.put_inflight( 1233 course, 1234 semester, 1235 target_user, 1236 phase, 1237 prid, 1238 taskid 1239 ) 1240 1241 if ts == "error": 1242 flask.flash( 1243 ( 1244 "ERROR: Failed to check evaluation status. Try" 1245 + " refreshing this page, and if the problem persists," 1246 + " contact {}." 1247 ).format( 1248 usual_config_value( 1249 "SUPPORT_LINK", 1250 task_info, 1251 task=taskid 1252 ) 1253 ) 1254 ) 1255 return flask.redirect( 1256 flask.url_for( 1257 destination_route, 1258 course=course, 1259 semester=semester, 1260 target_user=target_user, 1261 phase=phase, 1262 prid=prid, 1263 taskid=taskid 1264 ) 1265 ) 1266 elif ts is None: # another grading process is already in-flight 1267 flask.flash( 1268 ( 1269 "ERROR: Task {taskid} for project {prid} is" 1270 + "currently being evaluated. You must wait until" 1271 + "that process is complete before uploading another" 1272 + "submission. This should take no longer than" 1273 + "{timeout} seconds." 1274 ).format( 1275 prid=prid, 1276 taskid=taskid, 1277 timeout=usual_config_value( 1278 'FINAL_EVAL_TIMEOUT', 1279 task_info, 1280 task=taskid 1281 ) 1282 ) 1283 ) 1284 return flask.redirect( 1285 flask.url_for( 1286 destination_route, 1287 course=course, 1288 semester=semester, 1289 target_user=target_user, 1290 phase=phase, 1291 prid=prid, 1292 taskid=taskid 1293 ) 1294 ) 1295 1296 # Start the evaluation process (we don't wait for it) 1297 launch_potluck( 1298 course, 1299 semester, 1300 target_user, 1301 taskid, 1302 target, 1303 logfile, 1304 reportfile 1305 ) 1306 1307 return flask.redirect( 1308 flask.url_for( 1309 destination_route, 1310 course=course, 1311 semester=semester, 1312 target_user=target_user, 1313 phase=phase, 1314 prid=prid, 1315 taskid=taskid 1316 ) 1317 ) 1318 1319 1320def which_exe(target, cwd='.'): 1321 """ 1322 shutil.which 2/3 shim. Prepends cwd to current PATH. 1323 """ 1324 if sys.version_info[0] < 3: 1325 finder = subprocess.Popen( 1326 [ 'which', target ], 1327 cwd=cwd, 1328 stdout=subprocess.PIPE 1329 ) 1330 out, err = finder.communicate() 1331 return out.strip() 1332 else: 1333 return shutil.which( 1334 "potluck_eval", 1335 path=cwd + ':' + os.getenv("PATH") 1336 ) 1337 1338 1339def launch_potluck( 1340 course, 1341 semester, 1342 username, 1343 taskid, 1344 target_file, 1345 logfile, 1346 reportfile, 1347 wait=False 1348): 1349 """ 1350 Launches the evaluation process. By default this is fire-and-forget; 1351 we'll look for the output file to determine whether it's finished or 1352 not. However, by passing wait=True you can have the function wait for 1353 the process to terminate before returning. 1354 """ 1355 eval_dir = storage.evaluation_directory(course, semester) 1356 1357 task_info = storage.get_task_info(course, semester) 1358 1359 pev_python = usual_config_value( 1360 "POTLUCK_EVAL_PYTHON", 1361 task_info, 1362 task=taskid, 1363 default=None 1364 ) 1365 if pev_python is None: 1366 python = [] 1367 else: 1368 python = [ pev_python ] 1369 1370 pev_script = usual_config_value( 1371 "POTLUCK_EVAL_SCRIPT", 1372 task_info, 1373 task=taskid, 1374 default=None 1375 ) 1376 if pev_script is None: 1377 potluck_exe = which_exe("potluck_eval", eval_dir) 1378 else: 1379 potluck_exe = os.path.join(os.getcwd(), pev_script) 1380 1381 potluck_args = [ 1382 "--task", taskid, 1383 "--user", username, 1384 "--target", os.path.abspath(target_file), 1385 "--outfile", os.path.abspath(reportfile), 1386 "--clean", 1387 ] 1388 1389 pev_import_from = usual_config_value( 1390 "POTLUCK_EVAL_IMPORT_FROM", 1391 task_info, 1392 task=taskid, 1393 default=None 1394 ) 1395 if pev_import_from is not None: 1396 import_dir = os.path.join(os.getcwd(), pev_import_from) 1397 potluck_args.extend(["--import-from", import_dir]) 1398 with open(logfile, 'wb') as log: 1399 if usual_config_value( 1400 "USE_XVFB", 1401 task_info, 1402 task=taskid, 1403 default=False 1404 ): 1405 # Virtualise frame buffer for programs with graphics, so 1406 # they don't need to create Xwindow windows 1407 # '--auto-servernum', # create a new server?? 1408 # '--auto-display', 1409 # [2019/02/08] Peter: try this instead per comment in -h? 1410 xvfb_err_log = os.path.splitext(logfile)[0] + ".xvfb_errors.log" 1411 full_args = ( 1412 [ 1413 'xvfb-run', 1414 '-d', 1415 # [2019/02/11] Lyn: --auto-display doesn't work but -d 1416 # does (go figure, since they're supposed to be 1417 # synonyms!) 1418 '-e', # --error-file doesn't work 1419 xvfb_err_log, 1420 '--server-args', 1421 usual_config_value( 1422 "XVFB_SERVER_ARGS", 1423 task_info, 1424 task=taskid, 1425 default='-screen 0' 1426 ), # screen properties 1427 '--', 1428 ] + python + [ 1429 potluck_exe, 1430 ] 1431 + potluck_args 1432 ) 1433 else: 1434 # Raw potluck launch without XVFB 1435 full_args = python + [ potluck_exe ] + potluck_args 1436 1437 log.write( 1438 ("Full args: " + repr(full_args) + '\n').encode("utf-8") 1439 ) 1440 log.flush() 1441 1442 p = subprocess.Popen( 1443 full_args, 1444 cwd=eval_dir, 1445 stdout=log, 1446 stderr=log, 1447 ) 1448 1449 if wait: 1450 p.wait() 1451 1452 1453@app.route('/<course>/<semester>/extension/<prid>', methods=['GET']) 1454@flask_cas.login_required 1455@augment_arguments 1456def route_extension( 1457 course, 1458 semester, 1459 prid, 1460 # Augmented arguments 1461 username, 1462 is_admin, 1463 masquerade_as, 1464 effective_user, 1465 task_info 1466): 1467 """ 1468 Requests (and automatically grants) the default extension on the 1469 given problem set. The extension is applied to the initial phase 1470 only. For now, nonstandard extensions and revision extensions must be 1471 applied by hand-editing JSON files in the `extensions/` directory. 1472 """ 1473 try: 1474 pr = get_pr_obj(task_info, prid) 1475 except ValueError as e: 1476 flask.flash(str(e)) 1477 return goback(course, semester) 1478 1479 # Add status & time remaining info to project 1480 amend_project(course, semester, task_info, pr, effective_user) 1481 # TODO: triple-check the possibility of requesting an extension 1482 # during the period an extension would grant you but after the 1483 # initial deadline? 1484 1485 # Grant the extension 1486 if ( 1487 pr["status"]["state"] in ("unreleased", "released") 1488 and pr["status"]["initial_extension"] == 0 1489 ): 1490 succeeded = storage.set_extension( 1491 course, 1492 semester, 1493 effective_user, 1494 prid, 1495 "initial" 1496 ) 1497 if succeeded: 1498 flask.flash("Extension granted for {}.".format(prid)) 1499 else: 1500 flask.flash("Failed to grant extension for {}.".format(prid)) 1501 elif pr["status"]["state"] not in ("unreleased", "released"): 1502 flask.flash( 1503 ( 1504 "It is too late to request an extension for {}. You must" 1505 " request extensions before the deadline for each" 1506 " project." 1507 ).format(prid) 1508 ) 1509 elif pr["status"]["initial_extension"] != 0: 1510 flask.flash( 1511 "You have already been granted an extension on {}.".format(prid) 1512 ) 1513 else: 1514 flask.flash( 1515 "You cannot take an extension on {}.".format(prid) 1516 ) 1517 1518 # Send them back to the dashboard or wherever they came from 1519 return goback(course, semester) 1520 1521 1522@app.route( 1523 '/<course>/<semester>/set_extensions/<target_user>/<prid>', 1524 methods=['POST'] 1525) 1526@flask_cas.login_required 1527@augment_arguments 1528def route_set_extensions( 1529 course, 1530 semester, 1531 target_user, 1532 prid, 1533 # Augmented arguments 1534 username, 1535 is_admin, 1536 masquerade_as, 1537 effective_user, 1538 task_info 1539): 1540 """ 1541 Form target for editing extensions for a particular user/project. 1542 Only admins can use this route. Can be used to set custom extension 1543 values for both initial and revised deadlines. 1544 1545 Note that for now, we're also using it for exercise extensions. 1546 TODO: Fix that, since it means that if an exercise has the same ID 1547 as a project, their extensions will overwrite each other!!! 1548 """ 1549 if not is_admin: 1550 flask.flash("Only admins can grant extensions.") 1551 return goback(course, semester) 1552 1553 # Get initial/revision extension values from submitted form values 1554 try: 1555 initial = int(flask.request.form["initial"]) 1556 except Exception: 1557 initial = None 1558 1559 try: 1560 revision = int(flask.request.form["revision"]) 1561 except Exception: 1562 revision = None 1563 1564 # At least one or the other needs to be valid 1565 if initial is None and revision is None: 1566 flask.flash( 1567 ( 1568 "To set extensions, you must specify either an initial" 1569 " or a revision value, and both values must be integers." 1570 ) 1571 ) 1572 return goback(course, semester) 1573 1574 # Grant the extension(s) 1575 if initial is not None: 1576 # TODO: Use only_from here to help prevent race conditions on 1577 # loading the HTML page that displays the extension values 1578 succeeded = storage.set_extension( 1579 course, 1580 semester, 1581 target_user, 1582 prid, 1583 "initial", 1584 initial 1585 ) 1586 if succeeded: 1587 flask.flash( 1588 "Set {}h initial extension for {} on {}.".format( 1589 initial, 1590 target_user, 1591 prid 1592 ) 1593 ) 1594 else: 1595 flask.flash( 1596 "Failed to grant initial extension for {} on {} (try again?)." 1597 .format(target_user, prid) 1598 ) 1599 1600 if revision is not None: 1601 succeeded = storage.set_extension( 1602 course, 1603 semester, 1604 target_user, 1605 prid, 1606 "revision", 1607 revision 1608 ) 1609 if succeeded: 1610 flask.flash( 1611 "Set {}h revision extension for {} on {}.".format( 1612 revision, 1613 target_user, 1614 prid 1615 ) 1616 ) 1617 else: 1618 flask.flash( 1619 "Failed to grant revision extension for {} on {} (try again?)." 1620 .format(target_user, prid) 1621 ) 1622 1623 # Send them back to the dashboard or wherever they came from 1624 return goback(course, semester) 1625 1626 1627@app.route( 1628 '/<course>/<semester>/manage_extensions/<target_user>', 1629 methods=['GET'] 1630) 1631@flask_cas.login_required 1632@augment_arguments 1633def route_manage_extensions( 1634 course, 1635 semester, 1636 target_user, 1637 # Augmented arguments 1638 username, 1639 is_admin, 1640 masquerade_as, 1641 effective_user, 1642 task_info 1643): 1644 """ 1645 Admin-only route that displays a list of forms for each project 1646 showing current extension values and allowing the user to edit them 1647 and press a button to update them. 1648 """ 1649 if not is_admin: 1650 flask.flash("Only admins can grant extensions.") 1651 return goback(course, semester) 1652 1653 # Get extensions info for the target user for all projects 1654 amend_task_info(course, semester, task_info, target_user) 1655 1656 # Amend exercise statuses (using blank outcomes since we're only 1657 # using the extension info) 1658 amend_exercises(course, semester, task_info, {}, target_user) 1659 1660 return flask.render_template( 1661 'extension_manager.j2', 1662 course_name=task_info.get("course_name", course), 1663 course=course, 1664 semester=semester, 1665 username=username, 1666 is_admin=is_admin, 1667 masquerade_as=masquerade_as, 1668 task_info=task_info, 1669 target_user=target_user 1670 ) 1671 1672 1673@app.route( 1674 '/<course>/<semester>/set_eval/<target_user>/<phase>/<prid>/<taskid>', 1675 methods=['POST'] 1676) 1677@flask_cas.login_required 1678@augment_arguments 1679def route_set_evaluation( 1680 course, 1681 semester, 1682 target_user, 1683 phase, 1684 prid, 1685 taskid, 1686 # Augmented arguments 1687 username, 1688 is_admin, 1689 masquerade_as, 1690 effective_user, 1691 task_info 1692): 1693 """ 1694 Form target for editing custom evaluation info for a particular 1695 user/project. Only admins can use this route. Can be used to set 1696 custom notes and/or a grade override for a particular 1697 user/phase/project/task. 1698 """ 1699 if not is_admin: 1700 # TODO: Grader role! 1701 flask.flash("Only admins can edit evaluations.") 1702 return goback(course, semester) 1703 1704 # Get notes & overrides from form values 1705 try: 1706 notes = flask.request.form["notes_md"] 1707 except Exception: 1708 notes = None 1709 1710 try: 1711 override = flask.request.form["override"] 1712 except Exception: 1713 override = None 1714 1715 # Attempt to convert to a number 1716 if override is not None: 1717 try: 1718 override = float(override) 1719 except Exception: 1720 pass 1721 1722 try: 1723 timeliness = flask.request.form["timeliness_override"] 1724 except Exception: 1725 timeliness = None 1726 1727 # Attempt to convert to a number 1728 if timeliness is not None: 1729 try: 1730 timeliness = float(timeliness) 1731 except Exception: 1732 pass 1733 1734 # At least one of them needs to be valid 1735 if notes is None and override is None and timeliness is None: 1736 flask.flash( 1737 "To set an evaluation, you must specify at least one of: a" 1738 " notes string, a grade override, or a timeliness override." 1739 ) 1740 return goback(course, semester) 1741 1742 # Turn Nones into empty strings: 1743 if notes is None: 1744 notes = '' 1745 1746 if override is None: 1747 override = '' 1748 1749 if timeliness is None: 1750 timeliness = '' 1751 1752 # TODO: Create and use an only_from mechanism here to help 1753 # prevent race conditions on loading the HTML page that displays 1754 # the old evaluation values? 1755 succeeded = storage.set_evaluation( 1756 course, 1757 semester, 1758 target_user, 1759 phase, 1760 prid, 1761 taskid, 1762 notes, 1763 override, 1764 timeliness 1765 ) 1766 if succeeded: 1767 flask.flash( 1768 "Updated evaluation for {} on {} {} {} submission.".format( 1769 target_user, 1770 prid, 1771 taskid, 1772 phase 1773 ) 1774 ) 1775 else: 1776 flask.flash( 1777 ( 1778 "Failed to update evaluation for {} on {} {} {}" 1779 " submission." 1780 ).format( 1781 target_user, 1782 prid, 1783 taskid, 1784 phase 1785 ) 1786 ) 1787 1788 # Send them to the evaluation-editing page 1789 return flask.redirect( 1790 flask.url_for( 1791 'route_evaluate', 1792 course=course, 1793 semester=semester, 1794 target_user=target_user, 1795 phase=phase, 1796 prid=prid, 1797 taskid=taskid 1798 ) 1799 ) 1800 1801 1802@app.route( 1803 '/<course>/<semester>/evaluate/<target_user>/<phase>/<prid>/<taskid>', 1804 methods=['GET'] 1805) 1806@flask_cas.login_required 1807@augment_arguments 1808def route_evaluate( 1809 course, 1810 semester, 1811 target_user, 1812 phase, 1813 prid, 1814 taskid, 1815 # Augmented arguments 1816 username, 1817 is_admin, 1818 masquerade_as, 1819 effective_user, 1820 task_info 1821): 1822 """ 1823 Displays student feedback and also includes a form at the top for 1824 adding a custom note and/or overriding the grade. 1825 """ 1826 if not is_admin: 1827 # TODO: Grader role! 1828 flask.flash("Only admins can evaluate submissions.") 1829 return goback(course, semester) 1830 1831 # Check roster and flash a warning if we're viewing feedback for a 1832 # user who is not on the roster... 1833 try: 1834 roster = storage.get_roster(course, semester) 1835 except Exception as e: 1836 flask.flash(str(e)) 1837 roster = None 1838 1839 if roster is None: 1840 flask.flash( 1841 "Warning: could not fetch roster to check if this user is on" 1842 " it." 1843 ) 1844 elif target_user not in roster: 1845 flask.flash("Warning: this user is not on the roster!") 1846 1847 # Get full feedback info 1848 pr_and_task = get_feedback_pr_and_task( 1849 task_info, 1850 course, 1851 semester, 1852 target_user, 1853 phase, 1854 prid, 1855 taskid 1856 ) 1857 if isinstance(pr_and_task, ValueError): 1858 flask.flash(str(pr_and_task)) 1859 return goback(course, semester) 1860 else: 1861 pr, task = pr_and_task 1862 1863 return flask.render_template( 1864 'evaluate.j2', 1865 course_name=task_info.get("course_name", course), 1866 course=course, 1867 semester=semester, 1868 username=username, 1869 is_admin=is_admin, 1870 masquerade_as=masquerade_as, 1871 effective_user=effective_user, 1872 target_user=target_user, 1873 phase=phase, 1874 pr=pr, 1875 task=task, 1876 task_info=task_info, 1877 fb_css=potluck.render.get_css(), 1878 fb_js=potluck.render.get_js(), 1879 score_basis=usual_config_value( 1880 "SCORE_BASIS", 1881 task_info, 1882 task=taskid, 1883 default=0 1884 ), 1885 timeliness_points=usual_config_value( 1886 "TIMELINESS_POINTS", 1887 task_info, 1888 task=taskid, 1889 default=0 1890 ), 1891 support_link=usual_config_value( 1892 "SUPPORT_LINK", 1893 task_info, 1894 task=taskid 1895 ) 1896 ) 1897 1898 1899@app.route('/<course>/<semester>/solution/<prid>/<taskid>', methods=['GET']) 1900@flask_cas.login_required 1901@augment_arguments 1902def route_solution( 1903 course, 1904 semester, 1905 prid, 1906 taskid, 1907 # Augmented arguments 1908 username, 1909 is_admin, 1910 masquerade_as, 1911 effective_user, 1912 task_info 1913): 1914 """ 1915 Visible only once a task's status is final, accounting for all 1916 extensions, and if the active user has an evaluated submission for 1917 the task, or another task in the same pool (or if they're an admin). 1918 Shows the solution code for a particular task, including a formatted 1919 version and a link to download the .py file. 1920 """ 1921 try: 1922 pr = get_pr_obj(task_info, prid) 1923 except ValueError as e: 1924 flask.flash("ValueError: " + str(e)) 1925 return goback(course, semester) 1926 1927 # Add status & time remaining info to project and objects 1928 amend_project(course, semester, task_info, pr, effective_user) 1929 1930 # Grab task object for this task 1931 try: 1932 task = get_task_obj(task_info, pr, taskid) 1933 except ValueError as e: 1934 flask.flash("ValueError: " + str(e)) 1935 return goback(course, semester) 1936 1937 # Find tasks in the same pool as this one: 1938 if "pool" in task: 1939 tasks_in_pool = [ 1940 tentry["id"] 1941 for tentry in pr["tasks"] 1942 if tentry.get("pool") == task["pool"] 1943 ] 1944 else: 1945 tasks_in_pool = [ task["id"] ] 1946 1947 # Grab task objects for each task in our pool and amend them: 1948 submitted_to_pool = False 1949 for taskid in tasks_in_pool: 1950 try: 1951 tobj = get_task_obj(task_info, pr, taskid) 1952 except ValueError as e: 1953 flask.flash("ValueError: " + str(e)) 1954 return goback(course, semester) 1955 1956 initial = tobj 1957 revised = copy.deepcopy(tobj) 1958 1959 amend_task( 1960 course, 1961 semester, 1962 task_info, 1963 prid, 1964 initial, 1965 effective_user, 1966 "initial" 1967 ) 1968 if initial.get("submitted"): 1969 submitted_to_pool = True 1970 break 1971 1972 amend_task( 1973 course, 1974 semester, 1975 task_info, 1976 prid, 1977 revised, 1978 effective_user, 1979 "revision" 1980 ) 1981 if revised.get("submitted"): 1982 submitted_to_pool = True 1983 break 1984 1985 # Check roster so that only students on the roster can view solutions 1986 # (we don't want future-semester students viewing previous-semester 1987 # solutions!). 1988 try: 1989 roster = storage.get_roster(course, semester) 1990 except Exception as e: 1991 flask.flash(str(e)) 1992 roster = None 1993 1994 if roster is None: 1995 msg = "Failed to load <code>roster.csv</code>." 1996 flask.flash(msg) 1997 return error_response(course, semester, username, msg) 1998 1999 if pr["status"]["state"] != "final": 2000 if is_admin: 2001 flask.flash( 2002 "Viewing solution code as admin; solution is not" 2003 + " visible to students until revision period is over." 2004 ) 2005 else: 2006 flask.flash( 2007 ( 2008 "You cannot view the solutions for {project} {task}" 2009 + " until the revision period is over." 2010 ).format(project=prid, task=taskid) 2011 ) 2012 return goback(course, semester) 2013 2014 elif effective_user not in roster: # not on the roster 2015 if is_admin: 2016 flask.flash( 2017 ( 2018 "Viewing solution code as admin; solution is not" 2019 + " visible to user {} as they are not on the roster" 2020 + " for this course/semester." 2021 ).format(effective_user) 2022 ) 2023 else: 2024 flask.flash( 2025 ( 2026 "You cannot view solutions for {course} {semester}" 2027 + " because you are not on the roster for that class." 2028 ).format(course=course, semester=semester) 2029 ) 2030 return goback(course, semester) 2031 2032 elif not ( 2033 usual_config_value( 2034 "DISPLAY_UNSUBMITTED_SOLUTIONS", 2035 task_info, 2036 task=taskid, 2037 default=False 2038 ) 2039 or submitted_to_pool 2040 ): 2041 # This user hasn't submitted this task, so we'd like to make it 2042 # possible for them to get an extension later without worrying about 2043 # whether they've accessed solution code in the mean time. 2044 if is_admin: 2045 flask.flash( 2046 ( 2047 "Viewing solution code as admin; solution is not" 2048 + " visible to user {} as they don't have a submission" 2049 + " for this task or another task in this pool." 2050 ).format(effective_user) 2051 ) 2052 else: 2053 flask.flash( 2054 ( 2055 "You cannot view the solution for {} {} becuase you" 2056 " haven't submitted that task or any task in the" 2057 " same pool." 2058 ).format(prid, taskid) 2059 ) 2060 return goback(course, semester) 2061 2062 # At this point, we've verified it's okay to display the solution: 2063 # the project is finalized, and the user is an admin or at least on 2064 # the roster for this particular course/semester, and the user has a 2065 # submission for this task, or at least to another task in the same 2066 # pool. 2067 2068 # We'd like the feedback CSS & JS because we're going to render code 2069 # as HTML using potluck. 2070 fb_css = potluck.render.get_css() 2071 fb_js = potluck.render.get_js() 2072 2073 # TODO: This feels a bit hardcoded... can we do better? 2074 # TODO: Multi-file stuff! 2075 soln_filename = os.path.join( 2076 storage.evaluation_directory(course, semester), 2077 "specs", 2078 task["id"], 2079 "soln", 2080 task["target"] 2081 ) 2082 2083 with open(soln_filename, 'r') as fin: 2084 soln_code = fin.read() 2085 2086 soln_code_html = potluck.render.render_code( 2087 taskid, 2088 soln_filename, 2089 soln_code 2090 ) 2091 2092 return flask.render_template( 2093 'solution.j2', 2094 course_name=task_info.get("course_name", course), 2095 course=course, 2096 semester=semester, 2097 username=username, 2098 is_admin=is_admin, 2099 masquerade_as=masquerade_as, 2100 effective_user=effective_user, 2101 pr=pr, 2102 task=task, 2103 task_info=task_info, 2104 soln_code=soln_code, 2105 rendered_soln=soln_code_html, 2106 fb_css=fb_css, 2107 fb_js=fb_js, 2108 support_link=usual_config_value( 2109 "SUPPORT_LINK", 2110 task_info, 2111 task=taskid 2112 ) 2113 ) 2114 2115 2116@app.route( 2117 '/<course>/<semester>/starter/<taskid>.zip', 2118 methods=['GET'] 2119) 2120@flask_cas.login_required 2121def route_starter_zip(course, semester, taskid): 2122 """ 2123 For each task, serves a cached copy of a zip file that includes all 2124 files in that task's starter directory (+ subdirectories, including 2125 ones that are symlinks). If the cached zip does not exist, or if it's 2126 older than any of the files it needs to include, it will be 2127 generated. 2128 2129 `__pycache__` directories and any files they contain will not be 2130 included in the zip file. There is a configurable maximum number of 2131 files, to help detect issues caused by cyclic symbolic links; set 2132 `MAX_STARTER_FILES` to modify this from the default of 100000 (which 2133 is probably already enough that unzipping would be problematic?). 2134 2135 Raises an `OSError` if too many files are present. 2136 """ 2137 # TODO: This feels a bit hardcoded... can we do better? 2138 # Compute filenames and directories 2139 starter_dir = os.path.join( 2140 storage.evaluation_directory(course, semester), 2141 "specs", 2142 taskid, 2143 "starter" 2144 ) 2145 2146 # We 'detect' symlink loops without doing something like stating each 2147 # directory along the way by using a max # of files (the zip would 2148 # become unmanageable at some point anyways 2149 max_starter_files = usual_config_value( 2150 "MAX_STARTER_FILES", 2151 storage.get_task_info(course, semester), 2152 task=taskid, 2153 default=100000 2154 ) 2155 2156 # Note: a symlink-to-an-ancestor will cause an infinite loop here. 2157 starter_files = [] 2158 for dirpath, dirnames, filenames in os.walk( 2159 starter_dir, 2160 followlinks=True 2161 ): 2162 # Don't include __pycache__ directories 2163 if '__pycache__' in dirnames: 2164 dirnames.remove('__pycache__') 2165 2166 for filename in filenames: 2167 starter_files.append( 2168 os.path.relpath(os.path.join(dirpath, filename), starter_dir) 2169 ) 2170 if len(starter_files) > max_starter_files: 2171 raise OSError( 2172 ( 2173 "We've found more than {max_starter_files}" 2174 " starter files; it's likely that you have a" 2175 " symbolic link loop. Aborting..." 2176 ).format(max_starter_files=max_starter_files) 2177 ) 2178 2179 starter_zip = os.path.join( 2180 storage.evaluation_directory(course, semester), 2181 "specs", 2182 taskid, 2183 taskid + ".zip" 2184 ) 2185 2186 # Compute most-recent-modification time for any starter file 2187 updated_at = None 2188 for file in starter_files: 2189 full_path = os.path.join(starter_dir, file) 2190 mtime = os.stat(full_path).st_mtime 2191 if updated_at is None or updated_at < mtime: 2192 updated_at = mtime 2193 2194 # Check for freshness 2195 if ( 2196 not os.path.exists(starter_zip) 2197 or os.stat(starter_zip).st_mtime < updated_at 2198 ): 2199 # Zip up all the starter files, erasing and overwriting the old 2200 # zip file if it was there before 2201 with zipfile.ZipFile(starter_zip, 'w', zipfile.ZIP_DEFLATED) as zout: 2202 for file in starter_files: 2203 full_path = os.path.join(starter_dir, file) 2204 zout.write(full_path, taskid + '/' + file) 2205 2206 with open(starter_zip, 'rb') as fin: 2207 raw_bytes = fin.read() 2208 2209 return flask.Response(raw_bytes, mimetype='application/zip') 2210 2211 2212@app.route('/<course>/<semester>/gradesheet', methods=['GET']) 2213@flask_cas.login_required 2214@augment_arguments 2215def route_full_gradesheet( 2216 course, 2217 semester, 2218 # Augmented arguments 2219 username, 2220 is_admin, 2221 masquerade_as, 2222 effective_user, 2223 task_info 2224): 2225 """ 2226 Visible by admins only, this route displays an overview of the status 2227 of every student on the roster, for ALL exercises and projects. 2228 """ 2229 if not is_admin: 2230 flask.flash("You do not have permission to view gradesheets.") 2231 return goback(course, semester) 2232 2233 # Get the roster 2234 try: 2235 roster = storage.get_roster(course, semester) 2236 except Exception as e: 2237 flask.flash(str(e)) 2238 roster = None 2239 2240 if roster is None: 2241 msg = "Failed to load <code>roster.csv</code>." 2242 flask.flash(msg) 2243 return error_response(course, semester, username, msg) 2244 2245 # Assemble one gradesheet row per student 2246 rows = [] 2247 for stid in sorted( 2248 roster, 2249 key=lambda stid: ( 2250 roster[stid]["course_section"], 2251 roster[stid]["sortname"] 2252 ) 2253 ): 2254 if roster[stid]["course_section"] == "__hide__": 2255 continue 2256 2257 row = roster[stid] 2258 row_info = copy.deepcopy(task_info) 2259 row["task_info"] = row_info 2260 2261 # Get info for each exercise group and project 2262 grade_items = [] 2263 2264 # Grab latest-outcomes info for ALL exercises 2265 ex_outcomes = fetch_all_best_outcomes( 2266 course, 2267 semester, 2268 row["username"], 2269 row_info 2270 ) 2271 # Compute grades for each group 2272 amend_exercises( 2273 course, 2274 semester, 2275 row_info, 2276 ex_outcomes, 2277 row["username"] 2278 ) 2279 # Exercise groups 2280 for ex in row_info.get("exercises", []): 2281 if ex.get("hide"): 2282 continue 2283 item = copy.deepcopy(ex) 2284 item["name"] = ex["group"] 2285 item["grade"] = ex_combined_grade(ex, row_info) 2286 item["deadline"] = ex.get("timely") 2287 item["url"] = flask.url_for( 2288 'route_ex_gradesheet', 2289 course=course, 2290 semester=semester, 2291 group=ex["group"] 2292 ) 2293 grade_items.append(item) 2294 2295 # Amend info for all projects + tasks, and pick up grade items 2296 for pr in row_info.get("projects", row_info.get("psets", [])): 2297 if pr.get("hide"): 2298 continue 2299 amend_project_and_tasks( 2300 course, 2301 semester, 2302 row_info, 2303 pr, 2304 row["username"] 2305 ) 2306 2307 item = copy.deepcopy(pr) 2308 item["name"] = pr["id"] 2309 item["grade"] = project_combined_grade(pr, row_info) 2310 item["deadline"] = pr.get("due") 2311 item["url"] = flask.url_for( 2312 "route_gradesheet", 2313 course=course, 2314 semester=semester, 2315 prid=pr["id"] 2316 ) 2317 grade_items.append(item) 2318 2319 tasks = pr.get("tasks", []) 2320 2321 if len(tasks) > 1: 2322 item["parts"] = [] 2323 2324 for taskobj in tasks: 2325 part = copy.deepcopy(taskobj) 2326 part["name"] = taskobj["id"] 2327 part["grade"] = task_combined_grade(taskobj, row_info) 2328 part["url"] = flask.url_for( 2329 "route_gradesheet", 2330 course=course, 2331 semester=semester, 2332 prid=pr["id"] 2333 ) 2334 item["parts"].append(part) 2335 2336 # Sort items by deadlines and compute combined percentage 2337 grade_items.sort(key=lambda x: (x["deadline"], x["name"])) 2338 combined_num = 0 2339 combined_denom = 0 2340 for item in grade_items: 2341 due_at = potluck.time_utils.task_time__time( 2342 row_info, 2343 item["deadline"], 2344 default_time_of_day=row_info.get( 2345 "default_due_time_of_day", 2346 "23:59" 2347 ) 2348 ) 2349 now = potluck.time_utils.now() 2350 if due_at < now: 2351 combined_num += item["grade"] 2352 combined_denom += fallback_config_value( 2353 "SCORE_BASIS", 2354 item, 2355 row_info, 2356 app.config, 2357 DEFAULT_CONFIG 2358 ) 2359 2360 # Add grade items & combined percentage to row 2361 if combined_denom > 0: 2362 row["combined_pct"] = round( 2363 100 * (combined_num / combined_denom), 2364 1 2365 ) 2366 else: 2367 row["combined_pct"] = None 2368 row["grade_items"] = grade_items 2369 2370 rows.append(row) 2371 2372 # Get the student info 2373 try: 2374 student_info = storage.get_student_info(course, semester) 2375 except Exception as e: 2376 flask.flash(str(e)) 2377 student_info = None 2378 2379 return flask.render_template( 2380 'full_gradesheet.j2', 2381 course_name=task_info.get("course_name", course), 2382 course=course, 2383 semester=semester, 2384 username=username, 2385 is_admin=is_admin, 2386 masquerade_as=masquerade_as, 2387 task_info=task_info, 2388 grade_items=rows[0]["grade_items"], # arbitrary for header 2389 roster=rows, 2390 student_info=student_info 2391 ) 2392 2393 2394@app.route('/<course>/<semester>/gradesheet/<prid>', methods=['GET']) 2395@flask_cas.login_required 2396@augment_arguments 2397def route_gradesheet( 2398 course, 2399 semester, 2400 prid, 2401 # Augmented arguments 2402 username, 2403 is_admin, 2404 masquerade_as, 2405 effective_user, 2406 task_info 2407): 2408 """ 2409 Visible by admins only, this route displays an overview of the status 2410 of every student on the roster, with links to the feedback views for 2411 each student/project/phase/task. 2412 """ 2413 if not is_admin: 2414 flask.flash("You do not have permission to view gradesheets.") 2415 return goback(course, semester) 2416 2417 # Create base task info from logged-in user's perspective 2418 base_task_info = copy.deepcopy(task_info) 2419 amend_task_info(course, semester, base_task_info, username) 2420 2421 try: 2422 pr = get_pr_obj(base_task_info, prid) 2423 except ValueError as e: 2424 flask.flash(str(e)) 2425 return goback(course, semester) 2426 2427 # Get the roster 2428 try: 2429 roster = storage.get_roster(course, semester) 2430 except Exception as e: 2431 flask.flash(str(e)) 2432 roster = None 2433 2434 if roster is None: 2435 msg = "Failed to load <code>roster.csv</code>." 2436 flask.flash(msg) 2437 return error_response(course, semester, username, msg) 2438 2439 initial_task_times = {} 2440 revision_task_times = {} 2441 rows = [] 2442 for stid in sorted( 2443 roster, 2444 key=lambda stid: ( 2445 roster[stid]["course_section"], 2446 roster[stid]["sortname"] 2447 ) 2448 ): 2449 if roster[stid]["course_section"] == "__hide__": 2450 continue 2451 row = roster[stid] 2452 row_info = copy.deepcopy(task_info) 2453 row["task_info"] = row_info 2454 probj = get_pr_obj(row_info, prid) 2455 row["this_project"] = probj 2456 2457 amend_project_and_tasks( 2458 course, 2459 semester, 2460 row_info, 2461 probj, 2462 row["username"] 2463 ) 2464 2465 probj["total_time"] = 0 2466 2467 for taskobj in probj["tasks"]: 2468 taskid = taskobj["id"] 2469 time_spent = taskobj["time_spent"] 2470 if time_spent is not None: 2471 time_val = time_spent["time_spent"] 2472 if taskid not in initial_task_times: 2473 initial_task_times[taskid] = {} 2474 2475 if isinstance(time_val, (float, int)): 2476 initial_task_times[taskid][row["username"]] = time_val 2477 taskobj["initial_time_val"] = time_val 2478 if isinstance(probj["total_time"], (int, float)): 2479 probj["total_time"] += time_val 2480 elif isinstance(time_val, str) and time_val != "": 2481 taskobj["initial_time_val"] = time_val 2482 probj["total_time"] = "?" 2483 else: # empty string or None or the like 2484 taskobj["initial_time_val"] = "?" 2485 else: 2486 taskobj["initial_time_val"] = 0 2487 2488 rev_time_spent = taskobj.get("revision", {}).get("time_spent") 2489 if rev_time_spent is not None: 2490 rev_time_val = rev_time_spent["time_spent"] 2491 if taskid not in revision_task_times: 2492 revision_task_times[taskid] = {} 2493 2494 if isinstance(rev_time_val, (float, int)): 2495 revision_task_times[taskid][row["username"]] = rev_time_val 2496 taskobj["revision_time_val"] = rev_time_val 2497 if isinstance(probj["total_time"], (int, float)): 2498 probj["total_time"] += rev_time_val 2499 if isinstance(taskobj["initial_time_val"], (int, float)): 2500 taskobj["combined_time_val"] = ( 2501 taskobj["initial_time_val"] 2502 + rev_time_val 2503 ) 2504 elif ( # initial was a non-empty string 2505 isinstance(taskobj["initial_time_val"], str) 2506 and taskobj["initial_time_val"] not in ("", "?") 2507 ): 2508 taskobj["combined_time_val"] = "?" 2509 else: # empty string or None or the like 2510 taskobj["combined_time_val"] = rev_time_val 2511 2512 elif isinstance(rev_time_val, str) and rev_time_val != "": 2513 taskobj["revision_time_val"] = rev_time_val 2514 probj["total_time"] = "?" 2515 taskobj["combined_time_val"] = "?" 2516 2517 else: # empty string or None or the like 2518 taskobj["revision_time_val"] = "?" 2519 taskobj["combined_time_val"] = taskobj["initial_time_val"] 2520 2521 else: # no rev time spent 2522 taskobj["revision_time_val"] = "?" 2523 taskobj["combined_time_val"] = taskobj["initial_time_val"] 2524 2525 rows.append(row) 2526 2527 aggregate_times = { 2528 phase: { 2529 "average": { "project": 0 }, 2530 "median": { "project": 0 }, 2531 "75th": { "project": 0 }, 2532 } 2533 for phase in ("initial", "revision") 2534 } 2535 aggregate_times["all"] = {} 2536 for phase, times_group in [ 2537 ("initial", initial_task_times), 2538 ("revision", revision_task_times) 2539 ]: 2540 for taskid in times_group: 2541 times = list(times_group[taskid].values()) 2542 if len(times) == 0: 2543 avg = None 2544 med = None 2545 qrt = None 2546 elif len(times) == 1: 2547 avg = times[0] 2548 med = times[0] 2549 qrt = times[0] 2550 else: 2551 avg = sum(times) / len(times) 2552 med = percentile(times, 50) 2553 qrt = percentile(times, 75) 2554 2555 aggregate_times[phase]["average"][taskid] = avg 2556 aggregate_times[phase]["median"][taskid] = med 2557 aggregate_times[phase]["75th"][taskid] = qrt 2558 2559 if avg is not None: 2560 aggregate_times[phase]["average"]["project"] += avg 2561 aggregate_times[phase]["median"]["project"] += med 2562 aggregate_times[phase]["75th"]["project"] += qrt 2563 2564 # Compute total times taking zero-revision-times into account 2565 total_timespent_values = [] 2566 for student in [row["username"] for row in roster.values()]: 2567 total_timespent = 0 2568 for taskobj in probj["tasks"]: 2569 taskid = taskobj["id"] 2570 this_task_initial_times = initial_task_times.get(taskid, {}) 2571 this_task_revision_times = revision_task_times.get(taskid, {}) 2572 if student in this_task_initial_times: 2573 total_timespent += this_task_initial_times[student] 2574 if student in this_task_revision_times: 2575 total_timespent += this_task_revision_times[student] 2576 if total_timespent > 0: 2577 total_timespent_values.append(total_timespent) 2578 2579 if len(total_timespent_values) == 0: 2580 avg = 0 2581 med = 0 2582 qrt = 0 2583 elif len(total_timespent_values) == 1: 2584 avg = total_timespent_values[0] 2585 med = total_timespent_values[0] 2586 qrt = total_timespent_values[0] 2587 else: 2588 avg = sum(total_timespent_values) / len(total_timespent_values) 2589 med = percentile(total_timespent_values, 50) 2590 qrt = percentile(total_timespent_values, 75) 2591 2592 aggregate_times["all"]["average"] = avg 2593 aggregate_times["all"]["median"] = med 2594 aggregate_times["all"]["75th"] = qrt 2595 2596 # Get the student info 2597 try: 2598 student_info = storage.get_student_info(course, semester) 2599 except Exception as e: 2600 flask.flash(str(e)) 2601 student_info = None 2602 2603 return flask.render_template( 2604 'gradesheet.j2', 2605 course_name=task_info.get("course_name", course), 2606 course=course, 2607 semester=semester, 2608 username=username, 2609 is_admin=is_admin, 2610 masquerade_as=masquerade_as, 2611 task_info=task_info, 2612 pr=pr, 2613 roster=rows, 2614 student_info=student_info, 2615 aggregate_times=aggregate_times 2616 ) 2617 2618 2619@app.route('/<course>/<semester>/deliver', methods=['GET', 'POST']) 2620def route_deliver(course, semester): 2621 """ 2622 This route is accessible by anyone without login, because it is 2623 going to be posted to from Python scripts (see the `potluckDelivery` 2624 module). We will only accept submissions from users on the roster 2625 for the specified course/semester, although because there's no 2626 verification of user IDs, anyone could be sending submissions (TODO: 2627 Fix that using token-based verification!). Also, submissions may 2628 include multiple authors (e.g. when pair programming). 2629 2630 Submitted form data should have the following slots, with all 2631 strings encoded using utf-8: 2632 2633 - 'exercise': The ID of the exercise being submitted. 2634 - 'authors': A JSON-encoded list of usernames that the submission 2635 should be assigned to. 2636 - 'outcomes': A JSON-encoded list of `optimism` outcome-triples, 2637 each containing a boolean indicating success/failure, followed 2638 by a tag string indicating the file + line number of the check 2639 and a second string with the message describing the outcome of 2640 the check. 2641 - 'code': A JSON-encoded list of 2-element lists, each of which has 2642 a filename (or other code-source-identifying-string) and a code 2643 block (as a string). 2644 2645 If some of the data isn't in the formats specified above, a 400 error 2646 will be returned with a string describing what's wrong. 2647 """ 2648 # The potluckDelivery script prints the delivery URL, which may be 2649 # styled as a link. So we redirect anyone accessing this route with 2650 # a GET method to the dashboard. 2651 if flask.request.method == "GET": 2652 return flask.redirect( 2653 flask.url_for('route_dash', course=course, semester=semester) 2654 ) 2655 2656 # Process POST info... 2657 form = flask.request.form 2658 2659 # Get exercise ID 2660 exercise = form.get("exercise", "") 2661 if exercise == "": 2662 return ("Delivery did not specify an exercise.", 400) 2663 2664 # Get authors list, decode the JSON, and ensure it's a list of 2665 # strings. 2666 authorsString = form.get("authors", "") 2667 if authorsString == "": 2668 return ("Delivery did not specify authors.", 400) 2669 2670 try: 2671 authors = json.loads(authorsString) 2672 except Exception: 2673 return ("Specified authors list was not valid JSON.", 400) 2674 2675 if ( 2676 not isinstance(authors, list) 2677 or any(not isinstance(author, anystr) for author in authors) 2678 ): 2679 return ("Specified authors list was not a list of strings.", 400) 2680 2681 # Get outcomes list, decode the JSON, and ensure it's a list of 2682 # 3-element lists each containing a boolean and two strings. 2683 # An empty list of outcomes is not allowed. 2684 outcomesString = form.get("outcomes", "") 2685 if outcomesString == "": 2686 return ("Delivery did not specify any outcomes.", 400) 2687 2688 try: 2689 outcomes = json.loads(outcomesString) 2690 except Exception: 2691 return ("Specified outcomes list was not valid JSON.", 400) 2692 2693 if not isinstance(outcomes, list): 2694 return ("Outcomes object was not a list.", 400) 2695 2696 for i, outcome in enumerate(outcomes): 2697 if ( 2698 not isinstance(outcome, list) 2699 or len(outcome) != 3 2700 or not isinstance(outcome[0], bool) 2701 or not isinstance(outcome[1], anystr) 2702 or not isinstance(outcome[2], anystr) 2703 ): 2704 return ( 2705 "Outcome {} was invalid (each outcome must be a list" 2706 " containing a boolean and two strings)." 2707 ).format(i) 2708 2709 # Get code blocks, decode JSON, and ensure it's a list of pairs of 2710 # strings. It *is* allowed to be an empty list. 2711 codeString = form.get("code", "") 2712 if codeString == "": 2713 return ("Delivery did not specify any code.", 400) 2714 2715 try: 2716 codeBlocks = json.loads(codeString) 2717 except Exception: 2718 return ("Specified code list was not valid JSON.", 400) 2719 2720 if not isinstance(codeBlocks, list): 2721 return ("Code object was not a list.", 400) 2722 2723 for i, block in enumerate(codeBlocks): 2724 if ( 2725 not isinstance(block, list) 2726 or len(block) != 2 2727 or not all(isinstance(part, anystr) for part in block) 2728 ): 2729 return ( 2730 "Code block {} was invalid (each code block must be a" 2731 " list containing two strings)." 2732 ).format(i) 2733 2734 # Check authors against roster 2735 try: 2736 roster = storage.get_roster(course, semester) 2737 except Exception: 2738 return ( 2739 ( 2740 "Could not fetch roster for course {} {}." 2741 ).format(course, semester), 2742 400 2743 ) 2744 2745 if roster is None: 2746 return ( 2747 ( 2748 "There is no roster for course {} {}." 2749 ).format(course, semester), 2750 400 2751 ) 2752 2753 for author in authors: 2754 if author not in roster: 2755 return ( 2756 ( 2757 "Author '{}' is not on the roster for {} {}. You" 2758 " must use your username when specifying an author." 2759 ).format(author, course, semester), 2760 400 2761 ) 2762 2763 # Grab task info and amend it just to determine egroup phases 2764 task_info = storage.get_task_info(course, semester) 2765 2766 # Record the outcomes list for each author (extensions might be 2767 # different so credit might differ per author) 2768 shared_status = None 2769 statuses = {} 2770 for author in authors: 2771 2772 # Get exercise info so we can actually figure out what the 2773 # evaluation would be for these outcomes. 2774 if task_info is None: 2775 return ( 2776 "Failed to load tasks.json for {} {}.".format( 2777 course, 2778 semester 2779 ), 2780 400 2781 ) 2782 2783 # Per-author phase/extension info 2784 this_author_task_info = copy.deepcopy(task_info) 2785 amend_exercises(course, semester, this_author_task_info, {}, author) 2786 2787 einfo = None 2788 for group in this_author_task_info.get("exercises", []): 2789 elist = group["exercises"] 2790 2791 # Make allowances for old format 2792 if isinstance(elist, dict): 2793 for eid in elist: 2794 elist[eid]['id'] = eid 2795 elist = list(elist.values()) 2796 2797 for ex in elist: 2798 if exercise == ex['id']: 2799 einfo = ex 2800 break 2801 if einfo is not None: 2802 break 2803 2804 if einfo is None: 2805 return ( 2806 "Exercise '{}' is not listed in {} {}.".format( 2807 exercise, 2808 course, 2809 semester 2810 ), 2811 400 2812 ) 2813 2814 status, ecredit, gcredit = exercise_credit(einfo, outcomes) 2815 statuses[author] = status 2816 2817 if shared_status is None: 2818 shared_status = status 2819 elif shared_status == "mixed" or shared_status != status: 2820 shared_status = "mixed" 2821 # else it remains the same 2822 2823 storage.save_outcomes( 2824 course, 2825 semester, 2826 author, 2827 exercise, 2828 authors, 2829 outcomes, 2830 codeBlocks, 2831 status, 2832 ecredit, 2833 gcredit 2834 ) 2835 2836 if shared_status == "mixed": 2837 message = ( 2838 "Submission accepted: {}/{} checks passed, but status is" 2839 " different for different authors:\n{}" 2840 ).format( 2841 len([x for x in outcomes if x[0]]), 2842 len(outcomes), 2843 '\n'.join( 2844 " for {}: {}".format(author, status) 2845 for (author, status) in statuses.items() 2846 ) 2847 ) 2848 if any(status != "complete" for status in statuses.values()): 2849 message += ( 2850 "\nNote: this submission is NOT complete for all authors." 2851 ) 2852 else: 2853 message = ( 2854 "Submission accepted: {}/{} checks passed and status is {}." 2855 ).format( 2856 len([x for x in outcomes if x[0]]), 2857 len(outcomes), 2858 shared_status 2859 ) 2860 if shared_status != "complete": 2861 message += "\nNote: this submission is NOT complete." 2862 2863 return message 2864 2865 2866@app.route( 2867 '/<course>/<semester>/exercise/<target_user>/<eid>', 2868 methods=['GET'] 2869) 2870@flask_cas.login_required 2871@augment_arguments 2872def route_exercise( 2873 course, 2874 semester, 2875 target_user, 2876 eid, 2877 # Augmented arguments 2878 username, 2879 is_admin, 2880 masquerade_as, 2881 effective_user, 2882 task_info 2883): 2884 # Check view permission 2885 if target_user != effective_user and not is_admin: 2886 return error_response( 2887 course, 2888 semester, 2889 username, 2890 "You are not allowed to view exercises for {}.".format(target_user) 2891 ) 2892 elif target_user != effective_user: 2893 flask.flash("Viewing exercise for {}.".format(target_user)) 2894 2895 # From here on we treat the effective user as the target user 2896 effective_user = target_user 2897 2898 # Check roster and flash a warning if we're viewing feedback for a 2899 # user who is not on the roster... 2900 try: 2901 roster = storage.get_roster(course, semester) 2902 except Exception as e: 2903 flask.flash(str(e)) 2904 roster = None 2905 2906 if roster is None: 2907 flask.flash( 2908 "Warning: could not fetch roster to check if this user is on" 2909 " it." 2910 ) 2911 elif effective_user not in roster: 2912 flask.flash("Warning: this user is not on the roster!") 2913 2914 # Get exercise info: 2915 einfo = None 2916 ginfo = None 2917 for group in task_info.get("exercises", []): 2918 elist = group["exercises"] 2919 2920 # Make allowances for old format 2921 if isinstance(elist, dict): 2922 for eid in elist: 2923 elist[eid]['id'] = eid 2924 elist = list(elist.values()) 2925 2926 for ex in elist: 2927 if eid == ex['id']: 2928 ginfo = group 2929 einfo = ex 2930 break 2931 if einfo is not None: 2932 break 2933 2934 # Grab best-outcomes info for the exercise in question 2935 outcomes = fetch_all_best_outcomes( 2936 course, 2937 semester, 2938 effective_user, 2939 task_info, 2940 only_exercises={eid} 2941 ) 2942 2943 # Amend exercises to set statuses 2944 amend_exercises(course, semester, task_info, outcomes, target_user) 2945 2946 # Get particular outcome 2947 results = outcomes.get( 2948 eid, 2949 { # default when exercise hasn't been submitted 2950 "submitted_at": None, 2951 "on_time": True, 2952 "authors": [], 2953 "outcomes": [], 2954 "code": [], 2955 "status": "unsubmitted", 2956 "credit": None 2957 } 2958 ) 2959 2960 # Render override notes as HTML 2961 if results['code'] == '__override__': 2962 results['outcomes'] = potluck.render.render_markdown( 2963 results['outcomes'] 2964 ) 2965 2966 # Grab all attempts by category for the target exercise 2967 all_submissions = [] 2968 for category in ["override", "full", "partial", "none"]: 2969 allForCat = storage.fetch_outcomes( 2970 course, 2971 semester, 2972 effective_user, 2973 eid, 2974 category 2975 ) 2976 if allForCat is None: 2977 allForCat = [] 2978 all_submissions.extend(allForCat) 2979 legacy = storage.fetch_old_outcomes(course, semester, effective_user, eid) 2980 if legacy is not None: 2981 all_submissions.extend(legacy) 2982 2983 # Get deadline info 2984 deadline = get_exercise_deadline( 2985 course, 2986 semester, 2987 effective_user, 2988 task_info, 2989 ginfo 2990 ) 2991 2992 # Update all submissions to set on_time & group_credit values 2993 for sub in all_submissions: 2994 storage.update_submission_credit( 2995 sub, 2996 deadline, 2997 usual_config_value( 2998 "LATE_EXERCISE_CREDIT_FRACTION", 2999 task_info, 3000 exercise=eid, 3001 default=0.5 3002 ) 3003 ) 3004 # Render override notes as HTML 3005 if sub['code'] == '__override__': 3006 sub['outcomes'] = potluck.render.render_markdown( 3007 sub['outcomes'] 3008 ) 3009 3010 # Sort by submission time, with a few fall-backs 3011 all_submissions.sort( 3012 key=lambda outcome: ( 3013 outcome.get("submitted_at", "_") == "on_time", 3014 outcome.get("submitted_at", "_") == "late", 3015 outcome.get("submitted_at", "_"), 3016 outcome.get("status", "_"), 3017 outcome.get("group_credit", "_"), 3018 outcome.get("credit", "_"), 3019 outcome.get("code", "_"), 3020 ) 3021 ) 3022 3023 return flask.render_template( 3024 'exercise.j2', 3025 course_name=task_info.get("course_name", course), 3026 course=course, 3027 semester=semester, 3028 username=username, 3029 is_admin=is_admin, 3030 masquerade_as=masquerade_as, 3031 target_user=target_user, 3032 task_info=task_info, 3033 eid=eid, 3034 ginfo=ginfo, 3035 einfo=einfo, 3036 results=results, 3037 all_submissions=all_submissions 3038 ) 3039 3040 3041@app.route( 3042 '/<course>/<semester>/ex_override/<target_user>/<eid>', 3043 methods=['POST'] 3044) 3045@flask_cas.login_required 3046@augment_arguments 3047def route_exercise_override( 3048 course, 3049 semester, 3050 target_user, 3051 eid, 3052 # Augmented arguments 3053 username, 3054 is_admin, 3055 masquerade_as, 3056 effective_user, 3057 task_info 3058): 3059 """ 3060 Accessible by admins only, this route is the form target for the 3061 exercise and/or exercise group override controls. 3062 """ 3063 if not is_admin: 3064 # TODO: Grader role! 3065 flask.flash("Only admins can set exercise overrides.") 3066 return goback(course, semester) 3067 3068 # Get form values: 3069 try: 3070 override_for = flask.request.form["override_for"] 3071 except Exception: 3072 flask.flash("Override target (exercise vs. group) not specified.") 3073 return goback(course, semester) 3074 3075 if override_for not in ["group", "exercise"]: 3076 flask.flash("Invalid override target '{}'.".format(override_for)) 3077 return goback(course, semester) 3078 3079 try: 3080 credit = flask.request.form["credit"] 3081 except Exception: 3082 flask.flash("Override credit value not specified.") 3083 return goback(course, semester) 3084 3085 if credit != '': 3086 try: 3087 credit = float(credit) 3088 except Exception: 3089 flask.flash("Invalid credit value '{}'.".format(credit)) 3090 return goback(course, semester) 3091 3092 try: 3093 note = flask.request.form["note"] 3094 except Exception: 3095 note = "-no explanation provided-" 3096 3097 try: 3098 status = flask.request.form["status"] 3099 except Exception: 3100 status = "auto" 3101 3102 if override_for == "group": 3103 group_id = None 3104 for egroup in task_info.get("exercises", []): 3105 egid = egroup["group"] 3106 for exercise in egroup.get("exercises", []): 3107 if exercise.get("id") == eid: 3108 group_id = egid 3109 break 3110 if group_id is not None: 3111 break 3112 if group_id is None: 3113 flask.flash( 3114 "Could not find group ID for exercise '{}'.".format(eid) 3115 ) 3116 return goback(course, semester) 3117 3118 if status == "auto": 3119 status = "" 3120 storage.set_egroup_override( 3121 course, 3122 semester, 3123 target_user, 3124 group_id, 3125 override=credit, 3126 note=note, 3127 status=status 3128 ) 3129 3130 else: # Must be "exercise" 3131 3132 # check task_info and emit a warning if eid isn't there: 3133 found = False 3134 for egroup in task_info.get("exercises", []): 3135 for exercise in egroup.get("exercises", []): 3136 if exercise.get("id") == eid: 3137 found = True 3138 break 3139 if found: 3140 break 3141 if not found: 3142 flask.flash( 3143 ( 3144 "Warning: exercise '{}' is not listed in task info." 3145 " Entering override for it anyways." 3146 ).format(eid) 3147 ) 3148 3149 try: 3150 time_override = flask.request.form["time_override"] 3151 except Exception: 3152 time_override = None 3153 3154 if status == "auto": 3155 if credit >= 1.0: 3156 status = "complete" 3157 elif credit > 0: 3158 status = "partial" 3159 else: 3160 status = "incomplete" 3161 3162 storage.save_outcomes_override( 3163 course, 3164 semester, 3165 target_user, 3166 eid, 3167 username, 3168 note, 3169 status, 3170 credit, 3171 time_override=time_override 3172 ) 3173 3174 return goback(course, semester) 3175 3176 3177@app.route('/<course>/<semester>/ex_gradesheet/<group>', methods=['GET']) 3178@flask_cas.login_required 3179@augment_arguments 3180def route_ex_gradesheet( 3181 course, 3182 semester, 3183 group, 3184 # Augmented arguments 3185 username, 3186 is_admin, 3187 masquerade_as, 3188 effective_user, 3189 task_info 3190): 3191 """ 3192 Visible by admins only, this route displays an overview of the status 3193 of every student on the roster, with links to the extensions manager 3194 and to feedback views for each student. 3195 """ 3196 if not is_admin: 3197 flask.flash("You do not have permission to view gradesheets.") 3198 return goback(course, semester) 3199 3200 # Create base task info from logged-in user's perspective 3201 base_task_info = copy.deepcopy(task_info) 3202 amend_exercises(course, semester, base_task_info, {}, username) 3203 3204 egroups = base_task_info.get("exercises") 3205 if egroups is None: 3206 msg = "No exercises defined in tasks.json." 3207 flask.flash(msg) 3208 return error_response(course, semester, username, msg) 3209 3210 # Get the roster 3211 try: 3212 roster = storage.get_roster(course, semester) 3213 except Exception as e: 3214 flask.flash(str(e)) 3215 roster = None 3216 3217 if roster is None: 3218 msg = "Failed to load <code>roster.csv</code>." 3219 flask.flash(msg) 3220 return error_response(course, semester, username, msg) 3221 3222 # Figure out the index for this group 3223 group_index = None 3224 group_obj = None 3225 for i, group_entry in enumerate(egroups): 3226 if "timely_by" not in group_entry: 3227 print(group_entry) 3228 raise ValueError("HA") 3229 if group_entry["group"] == group: 3230 group_index = i 3231 group_obj = group_entry 3232 break 3233 3234 if group_index is None: 3235 msg = ( 3236 "Exercise group '{}' not found for this course/semester." 3237 ).format(group) 3238 flask.flash(msg) 3239 return error_response(course, semester, username, msg) 3240 3241 rows = [] 3242 for stid in sorted( 3243 roster, 3244 key=lambda stid: ( 3245 roster[stid]["course_section"], 3246 roster[stid]["sortname"] 3247 ) 3248 ): 3249 if roster[stid]["course_section"] == "__hide__": 3250 continue 3251 row = roster[stid] 3252 row_info = copy.deepcopy(task_info) 3253 row["task_info"] = row_info 3254 3255 # Grab latest-outcomes info for ALL exercises 3256 row_outcomes = fetch_all_best_outcomes( 3257 course, 3258 semester, 3259 stid, 3260 row_info, 3261 only_groups=[group] 3262 ) 3263 row["outcomes"] = row_outcomes 3264 3265 # Amend this row's exercises w/ this student's outcomes 3266 amend_exercises(course, semester, row_info, row_outcomes, stid) 3267 3268 # Fetch group from amended task_info via group index 3269 this_group = row_info["exercises"][group_index] 3270 3271 row["this_group"] = this_group 3272 3273 rows.append(row) 3274 3275 # Get the student info 3276 try: 3277 student_info = storage.get_student_info(course, semester) 3278 except Exception as e: 3279 flask.flash(str(e)) 3280 student_info = None 3281 3282 return flask.render_template( 3283 'exercise_gradesheet.j2', 3284 course_name=task_info.get("course_name", course), 3285 course=course, 3286 semester=semester, 3287 username=username, 3288 is_admin=is_admin, 3289 masquerade_as=masquerade_as, 3290 task_info=task_info, 3291 group=group, 3292 group_obj=group_obj, 3293 roster=rows, 3294 student_info=student_info 3295 ) 3296 3297 3298#---------# 3299# Helpers # 3300#---------# 3301 3302def error_response(course, semester, username, cause): 3303 """ 3304 Shortcut for displaying major errors to the users so that they can 3305 bug the support line instead of just getting a pure 404. 3306 """ 3307 return flask.render_template( 3308 'error.j2', 3309 course_name=course, 3310 course=course, 3311 semester=semester, 3312 username=username, 3313 announcements="", 3314 support_link=fallback_config_value( 3315 "SUPPORT_LINK", 3316 app.config, 3317 DEFAULT_CONFIG 3318 ), 3319 error=cause, 3320 task_info={} 3321 ) 3322 3323 3324def get_pr_obj(task_info, prid): 3325 """ 3326 Gets the project object with the given ID. Raises a ValueError if 3327 there is no such object, or if there are multiple matches. 3328 """ 3329 psmatches = [ 3330 pr 3331 for pr in task_info.get("projects", task_info["psets"]) 3332 if pr["id"] == prid 3333 ] 3334 if len(psmatches) == 0: 3335 raise ValueError("Unknown problem set '{}'".format(prid)) 3336 elif len(psmatches) > 1: 3337 raise ValueError("Multiple problem sets with ID '{}'!".format(prid)) 3338 else: 3339 return psmatches[0] 3340 3341 3342def get_task_obj(task_info, pr_obj, taskid, redirect=Exception): 3343 """ 3344 Extracts a task object with the given ID from the given task info 3345 and project objects, merging project-specific fields with universal 3346 task fields. Raises a ValueError if there is no matching task object 3347 or if there are multiple matches. 3348 """ 3349 universal = task_info["tasks"].get(taskid, None) 3350 taskmatches = [task for task in pr_obj["tasks"] if task["id"] == taskid] 3351 if len(taskmatches) == 0: 3352 raise ValueError( 3353 "Problem set {} has no task '{}'".format(pr_obj["id"], taskid) 3354 ) 3355 elif universal is None: 3356 raise ValueError( 3357 ( 3358 "Problem set {} has a task '{}' but that task has no" 3359 " universal specification." 3360 ).format(pr_obj["id"], taskid) 3361 ) 3362 elif len(taskmatches) > 1: 3363 raise ValueError( 3364 "Multiple tasks in problem set {} with ID '{}'!".format( 3365 pr_obj["id"], 3366 taskid 3367 ) 3368 ) 3369 else: 3370 result = {} 3371 result.update(universal) 3372 result.update(taskmatches[0]) 3373 return result 3374 3375 3376def check_user_privileges(admin_info, username): 3377 """ 3378 Returns a pair containing a boolean indicating whether a user is an 3379 admin or not, and either None, or a string indicating the username 3380 that the given user is masquerading as. 3381 3382 Requires admin info as returned by get_admin_info. 3383 """ 3384 admins = admin_info.get("admins", []) 3385 is_admin = username in admins 3386 3387 masquerade_as = None 3388 # Only admins can possibly masquerade 3389 if is_admin: 3390 masquerade_as = admin_info.get("MASQUERADE", {}).get(username) 3391 # You cannot masquerade as an admin 3392 if masquerade_as in admins: 3393 flask.flash("Error: You cannot masquerade as another admin!") 3394 masquerade_as = None 3395 elif admin_info.get("MASQUERADE", {}).get(username): 3396 print( 3397 ( 3398 "Warning: User '{}' cannot masquerade because they are" 3399 + "not an admin." 3400 ).format(username) 3401 ) 3402 3403 return (is_admin, masquerade_as) 3404 3405 3406def set_pause_time(admin_info, task_info, username, masquerade_as=None): 3407 """ 3408 Sets the PAUSE_AT value in the given task_info object based on any 3409 PAUSE_USERS entries for either the given true username or the given 3410 masquerade username. The pause value for the username overrides the 3411 value for the masqueraded user, so that by setting PAUSE_AT for an 3412 admin account plus creating a masquerade entry, you can act as any 3413 user at any point in time. 3414 """ 3415 pu = admin_info.get("PAUSE_USERS", {}) 3416 if username in pu: 3417 task_info["PAUSE_AT"] = pu[username] 3418 elif masquerade_as in pu: 3419 task_info["PAUSE_AT"] = pu[masquerade_as] 3420 elif "PAUSE_AT" in admin_info: 3421 task_info["PAUSE_AT"] = admin_info["PAUSE_AT"] 3422 # else we don't set PAUSE_AT at all 3423 3424 3425def amend_task_info(course, semester, task_info, username): 3426 """ 3427 Amends task info object with extra keys in each project to indicate 3428 project state. Also adds summary information to each task of the 3429 project based on user feedback generated so far. Also checks potluck 3430 inflight status and adds a "submitted" key to each task where 3431 appropriate. Template code should be careful not to reveal feedback 3432 info not warranted by the current project state. 3433 """ 3434 for project in task_info.get("projects", task_info["psets"]): 3435 # Add status info to the project object: 3436 amend_project_and_tasks( 3437 course, 3438 semester, 3439 task_info, 3440 project, 3441 username 3442 ) 3443 3444 3445def amend_project_and_tasks( 3446 course, 3447 semester, 3448 task_info, 3449 project_obj, 3450 username 3451): 3452 """ 3453 Calls amend_project on the given project object, and then amend_task 3454 on each task in it, adding "revision" entries to each task w/ 3455 amended revision info. 3456 3457 Also adds "pool_status" keys to each task indicating the best status 3458 of any task that's pooled with that one. That will be 'complete' if 3459 there's a complete task in the pool, 'some_submission' if there's 3460 any unsubmitted task in the pool, an 'unsubmitted' if there are no 3461 submitted tasks in the pool. 3462 """ 3463 amend_project(course, semester, task_info, project_obj, username) 3464 # Add summary info to each task, and duplicate for revisions and 3465 # belated versions 3466 for task in project_obj["tasks"]: 3467 rev_task = copy.deepcopy(task) 3468 belated_task = copy.deepcopy(task) 3469 amend_task( 3470 course, 3471 semester, 3472 task_info, 3473 project_obj["id"], 3474 rev_task, 3475 username, 3476 "revision" 3477 ) 3478 task["revision"] = rev_task 3479 3480 amend_task( 3481 course, 3482 semester, 3483 task_info, 3484 project_obj["id"], 3485 belated_task, 3486 username, 3487 "belated" 3488 ) 3489 task["belated"] = belated_task 3490 3491 amend_task( 3492 course, 3493 semester, 3494 task_info, 3495 project_obj["id"], 3496 task, 3497 username, 3498 "initial" 3499 ) 3500 3501 # Figure out best status in each pool 3502 pool_statuses = {} 3503 for task in project_obj["tasks"]: 3504 # Get submission status 3505 status = task["submission_status"] 3506 if status not in ("unsubmitted", "complete"): 3507 status = "some_submission" 3508 3509 rev_status = task.get("revision", {}).get( 3510 "submission_status", 3511 "unsubmitted" 3512 ) 3513 if rev_status not in ("unsubmitted", "complete"): 3514 rev_status = "some_submission" 3515 bel_status = task.get("belated", {}).get( 3516 "submission_status", 3517 "unsubmitted" 3518 ) 3519 if bel_status not in ("unsubmitted", "complete"): 3520 bel_status = "some_submission" 3521 3522 # Figure out best status across revision/belated phases 3523 points = map( 3524 lambda x: { 3525 "unsubmitted": 0, 3526 "some_submission": 1, 3527 "complete": 2 3528 }[x], 3529 [status, rev_status, bel_status] 3530 ) 3531 status = { 3532 0: "unsubmitted", 3533 1: "some_submission", 3534 2: "complete" 3535 }[max(points)] 3536 3537 # Figure out this task's pool and update the score for that pool 3538 pool = task_pool(task) 3539 3540 prev_status = pool_statuses.get(pool) 3541 if ( 3542 prev_status is None 3543 or prev_status == "unsubmitted" 3544 or prev_status == "some_submission" and status == "complete" 3545 ): 3546 pool_statuses[pool] = status 3547 3548 # Now assign pool_status slots to each task 3549 for task in project_obj["tasks"]: 3550 pool = task_pool(task) 3551 task["pool_status"] = pool_statuses[pool] 3552 3553 3554def amend_project(course, semester, task_info, project_obj, username): 3555 """ 3556 Adds a "status" key to the given problem set object (which should be 3557 part of the given task info). The username is used to look up 3558 extension information. 3559 """ 3560 initial_ext = storage.get_extension( 3561 course, 3562 semester, 3563 username, 3564 project_obj["id"], 3565 "initial" 3566 ) 3567 if initial_ext is None: 3568 flask.flash( 3569 "Error fetching initial extension info (treating as 0)." 3570 ) 3571 initial_ext = 0 3572 3573 revision_ext = storage.get_extension( 3574 course, 3575 semester, 3576 username, 3577 project_obj["id"], 3578 "revision" 3579 ) 3580 if revision_ext is None: 3581 flask.flash( 3582 "Error fetching revision extension info (treating as 0)." 3583 ) 3584 revision_ext = 0 3585 3586 project_obj["status"] = project_status_now( 3587 username, 3588 task_info, 3589 project_obj, 3590 extensions=[initial_ext, revision_ext] 3591 ) 3592 3593 3594def amend_task(course, semester, task_info, prid, task, username, phase): 3595 """ 3596 Adds task-state-related keys to the given task object. The following 3597 keys are added: 3598 3599 - "feedback_summary": A feedback summary object (see 3600 `get_feedback_summary`). 3601 - "time_spent": The time spent info that the user entered for time 3602 spent when submitting the task (see 3603 `potluck_app.storage.fetch_time_spent` for the format). Will be 3604 None if that info isn't available. 3605 - "submitted": True if the user has attempted submission (even if 3606 there were problems) or if we have feedback for them (regardless 3607 of anything else). 3608 - "submitted_at": A `datetime.datetime` object representing when 3609 the submission was received, or None if there is no recorded 3610 submission. 3611 - "eval_elapsed": A `datetime.timedelta` that represents the time 3612 elapsed since evaluation was started for the submission (in 3613 possibly fractional seconds). 3614 - "eval_timeout": A `datetime.timedelta` representing the number 3615 of seconds before the evaluation process should time out. 3616 - "eval_status": The status of the evaluation process (see 3617 `get_inflight`). 3618 - "submission_status": A string representing the status of the 3619 submission overall. One of: 3620 - "unsubmitted": no submission yet. 3621 - "inflight": has been submitted; still being evaluated. 3622 - "unprocessed": evaluated but there's an issue (evaluation 3623 crashed or elsehow failed to generate feedback). 3624 - "issue": evaluated but there's an issue (evaluation warning or 3625 'incomplete' evaluation result.) 3626 - "complete": Evaluated and there's no major issue (grade may or 3627 may not be perfect, but it better than "incomplete"). 3628 - "submission_icon": A one-character string used to represent the 3629 submission status. 3630 - "submission_desc": A brief human-readable version of the 3631 submission status. 3632 - "grade": A numerical grade value, derived from the evaluation via 3633 the EVALUATION_SCORES dictionary defined in the config file. If 3634 no grade has been assigned, it will be the string '?' instead of 3635 a number. Grade overrides are factored in if present. 3636 - "grade_overridden": A boolean indicating whether the grade was 3637 overridden or not. Will be true when there's an override active 3638 even if the override indicates the same score as the automatic 3639 grade. 3640 - "timeliness": A numerical score value for timeliness points, only 3641 present when an override has been issued. Normally, timeliness 3642 points are computed for an already-augmented task based on the 3643 initial/revised/belated submission statuses. 3644 - "timeliness_overridden": A boolean indicating whether the 3645 timeliness score was overridden or not. 3646 - "notes": A string indicating the markdown source for custom notes 3647 applied to the task by an evaluator. 3648 - "notes_html": A string of HTML code rendered from the notes 3649 markdown. 3650 - "max_score": A number representing the maximum score possible 3651 based on the phase of the submission, either SCORE_BASIS for 3652 initial submissions, REVISION_MAX_SCORE for revisions, or 3653 BELATED_MAX_SCORE for belated submissions. 3654 - "max_revision_score": A number representing the max score possible 3655 on a revision of this task (regardless of whether this is an 3656 initial or revised submission) 3657 - "max_belated_score": A number representing the max score possible 3658 on a belated submission of this task (regardless of whether this 3659 is an initial, revised, or belated submission) 3660 """ 3661 # Fetch basic info 3662 task["feedback_summary"] = storage.get_feedback_summary( 3663 course, 3664 semester, 3665 task_info, 3666 username, 3667 phase, 3668 prid, 3669 task["id"] 3670 ) 3671 task["time_spent"] = storage.fetch_time_spent( 3672 course, 3673 semester, 3674 username, 3675 phase, 3676 prid, 3677 task["id"] 3678 ) 3679 3680 # Get submitted value 3681 # Note that we hedge here against the possibility that the feedback 3682 # summary isn't readable since some kinds of bad crashes of the 3683 # evaluator can cause that to happen (e.g., student accidentally 3684 # monkey-patches json.dump so that no report can be written). 3685 task["submitted"] = (task.get("feedback_summary") or {}).get("submitted") 3686 3687 # Get inflight info so we know about timeouts 3688 ts, logfile, reportfile, status = storage.get_inflight( 3689 course, 3690 semester, 3691 username, 3692 phase, 3693 prid, 3694 task["id"] 3695 ) 3696 3697 # Get current time (IN UTC) 3698 now = potluck.time_utils.now() 3699 3700 # Time submitted and time elapsed since submission 3701 if ts == "error": 3702 task["submitted_at"] = "unknown" 3703 task["eval_elapsed"] = "unknown" 3704 elif ts is not None: 3705 submit_time = potluck.time_utils.local_time( 3706 task_info, 3707 potluck.time_utils.time_from_timestring(ts) 3708 ) 3709 task["submitted_at"] = submit_time 3710 task["eval_elapsed"] = now - submit_time 3711 else: 3712 try: 3713 submit_time = potluck.time_utils.local_time( 3714 task_info, 3715 potluck.time_utils.time_from_timestring( 3716 task["feedback_summary"]["timestamp"] 3717 ) 3718 ) 3719 task["submitted_at"] = submit_time 3720 task["eval_elapsed"] = now - submit_time 3721 except Exception: 3722 task["submitted_at"] = None 3723 task["eval_elapsed"] = None 3724 3725 task["eval_timeout"] = datetime.timedelta( 3726 seconds=usual_config_value( 3727 "FINAL_EVAL_TIMEOUT", 3728 task_info, 3729 task=task["id"] 3730 ) 3731 ) 3732 3733 # Set eval_status 3734 if ts == "error": 3735 task["eval_status"] = "unknown" 3736 else: 3737 task["eval_status"] = status 3738 3739 # Override submitted value 3740 if status is not None: 3741 task["submitted"] = True 3742 3743 # Add max score info 3744 max_score = usual_config_value( 3745 "SCORE_BASIS", 3746 task_info, 3747 task=task["id"], 3748 default=100 3749 ) 3750 revision_max = usual_config_value( 3751 "REVISION_MAX_SCORE", 3752 task_info, 3753 task=task["id"], 3754 default=100 3755 ) 3756 belated_max = usual_config_value( 3757 "BELATED_MAX_SCORE", 3758 task_info, 3759 task=task["id"], 3760 default=85 3761 ) 3762 if phase == "revision": 3763 task["max_score"] = revision_max 3764 elif phase == "belated": 3765 task["max_score"] = belated_max 3766 else: 3767 task["max_score"] = max_score 3768 task["max_revision_score"] = revision_max 3769 task["max_belated_score"] = belated_max 3770 3771 # Add grade info 3772 if task["eval_status"] in ("unknown", "initial", "in_progress"): 3773 task["grade"] = "?" 3774 elif task["eval_status"] in ("error", "expired"): 3775 task["grade"] = 0 3776 elif task["eval_status"] == "completed" or task["submitted"]: 3777 task["grade"] = usual_config_value( 3778 [ 3779 "EVALUATION_SCORES", 3780 task["feedback_summary"]["evaluation"] 3781 ], 3782 task_info, 3783 task=task["id"], 3784 default=usual_config_value( 3785 ["EVALUATION_SCORES", "__other__"], 3786 task_info, 3787 task=task["id"], 3788 default="???" 3789 ) 3790 ) 3791 if task["grade"] == "???": 3792 flask.flash( 3793 ( 3794 "Warning: evaluation '{}' has not been assigned a" 3795 + " grade value!" 3796 ).format( 3797 task["feedback_summary"]["evaluation"] 3798 ) 3799 ) 3800 task["grade"] = None 3801 else: 3802 task["grade"] = None 3803 3804 # Check for a grade override and grading note 3805 notes, notes_html, override, timeliness_override = get_evaluation_info( 3806 course, 3807 semester, 3808 username, 3809 phase, 3810 prid, 3811 task["id"] 3812 ) 3813 task["notes"] = notes 3814 task["notes_html"] = notes_html 3815 if override == '': 3816 task["grade_overridden"] = False 3817 else: 3818 task["grade_overridden"] = True 3819 task["grade"] = override 3820 3821 if timeliness_override == '': 3822 task["timeliness_overridden"] = False 3823 # No timeliness slot at all 3824 else: 3825 if phase != 'initial': 3826 flask.flash( 3827 ( 3828 "Warning: timeliness override {} for {} {} in phase" 3829 " {} will be ignored: only overrides for the" 3830 " initial phase are applied." 3831 ).format(timeliness_override, prid, task["id"], phase) 3832 ) 3833 task["timeliness_overridden"] = True 3834 task["timeliness"] = timeliness_override 3835 3836 # Set detailed submission status along with icon and description 3837 if task["eval_status"] == "unknown": 3838 task["submission_status"] = "inflight" 3839 task["submission_icon"] = "‽" 3840 task["submission_desc"] = "status unknown" 3841 if task["eval_status"] in ("initial", "in_progress"): 3842 task["submission_status"] = "inflight" 3843 task["submission_icon"] = "?" 3844 task["submission_desc"] = "evaluation in progress" 3845 elif task["eval_status"] in ("error", "expired"): 3846 task["submission_status"] = "unprocessed" 3847 task["submission_icon"] = "☹" 3848 task["submission_desc"] = "processing error" 3849 elif task["eval_status"] == "completed" or task["submitted"]: 3850 report = task["feedback_summary"] 3851 if report["warnings"]: 3852 task["submission_status"] = "issue" 3853 task["submission_icon"] = "✗" 3854 task["submission_desc"] = "major issue" 3855 elif report["evaluation"] == "incomplete": 3856 task["submission_status"] = "issue" 3857 task["submission_icon"] = "✗" 3858 task["submission_desc"] = "incomplete submission" 3859 elif report["evaluation"] == "not evaluated": 3860 task["submission_status"] = "unprocessed" 3861 task["submission_icon"] = "☹" 3862 task["submission_desc"] = "submission not evaluated" 3863 else: 3864 task["submission_status"] = "complete" 3865 task["submission_icon"] = "✓" 3866 task["submission_desc"] = "submitted" 3867 else: 3868 task["submission_status"] = "unsubmitted" 3869 task["submission_icon"] = "…" 3870 task["submission_desc"] = "not yet submitted" 3871 3872 3873def exercise_credit(exercise_info, outcomes): 3874 """ 3875 Returns a triple containing a submission status string, a number for 3876 exercise credit (or None), and a number for group credit (possibly 3877 0). These are based on whether the given outcomes list matches the 3878 expected outcomes for the given exercise info (should be an 3879 individual exercise dictionary). These values will NOT account for 3880 whether the submission is on-time or late. 3881 3882 The status string will be one of: 3883 3884 - "complete" if all outcomes are successful. 3885 - "partial" if some outcome failed, but at least one non-passive 3886 outcome succeeded (passive outcomes are those expected to succeed 3887 even when no code is written beyond the starter code, but which 3888 may fail if bad code is written). 3889 - "incomplete" if not enough outcomes are successful for partial 3890 completeness, or if there's an issue like the wrong number of 3891 outcomes being reported. 3892 3893 This function won't return it, but "unsubmitted" is another possible 3894 status string used elsewhere. 3895 """ 3896 group_credit = 0 3897 exercise_credit = None 3898 n_outcomes = len(outcomes) 3899 3900 exp_outcomes = exercise_info.get('count') 3901 if exp_outcomes is None and 'per_outcome' in exercise_info: 3902 exp_outcomes = len(exercise_info['per_outcome']) 3903 3904 if exp_outcomes is None: 3905 # Without info on how many outcomes we're expecting, we currently 3906 # don't have a way to know if an outcome is passive (or which 3907 # concept(s) it might deal with). 3908 # TODO: Less fragile way to associate outcomes w/ concepts! 3909 3910 # In this case, at least one outcome is required! 3911 if len(outcomes) == 0: 3912 submission_status = "incomplete" 3913 else: 3914 # Otherwise, all-success -> complete; one+ success -> partial 3915 passed = [outcome for outcome in outcomes if outcome[0]] 3916 if len(passed) == len(outcomes): 3917 submission_status = "complete" 3918 exercise_credit = 1 3919 elif len(passed) > 0: 3920 submission_status = "partial" 3921 exercise_credit = 0.5 3922 else: 3923 submission_status = "incomplete" 3924 exercise_credit = 0 3925 # Accumulate credit 3926 group_credit = exercise_credit 3927 elif n_outcomes != exp_outcomes: 3928 # If we have an expectation for the number of outcomes and the 3929 # actual number doesn't match that, we won't know which outcomes 3930 # might be passive vs. active, and we don't know how to map 3931 # outcomes onto per-outcome concepts... so we just ignore the 3932 # whole exercise and count it as not-yet-submitted. 3933 # TODO: Less fragile way to associate outcomes w/ concepts! 3934 submission_status = "incomplete" 3935 # No group credit accrues in this case 3936 elif len(outcomes) == 0: 3937 # this exercise doesn't contribute to any concept 3938 # statuses, and that's intentional. Since it doesn't 3939 # have outcomes, any submission counts as complete. 3940 submission_status = "complete" 3941 # Here we directly add to group credit but leave 3942 # exercise_credit as None. 3943 group_credit = 1 3944 else: 3945 # Get list of outcome indices for successes 3946 passed = [ 3947 i 3948 for i in range(len(outcomes)) 3949 if outcomes[i][0] 3950 ] 3951 # Get list of which outcomes are passive 3952 passives = exercise_info.get("passive", []) 3953 if len(passed) == n_outcomes: 3954 # If everything passed, the overall is a pass 3955 exercise_credit = 1 3956 submission_status = "complete" 3957 elif any(i not in passives for i in passed): 3958 # at least one non-passive passed -> partial 3959 exercise_credit = 0.5 3960 submission_status = "partial" 3961 else: 3962 # only passing tests were partial & at least one 3963 # failed -> no credit 3964 exercise_credit = 0 3965 submission_status = "incomplete" 3966 3967 # Set group credit 3968 group_credit = exercise_credit 3969 3970 return (submission_status, exercise_credit, group_credit) 3971 3972 3973def get_exercise_deadline( 3974 course, 3975 semester, 3976 username, 3977 task_info, 3978 egroup 3979): 3980 """ 3981 Given a particular course/semester/user, the task_info object, and 3982 an exercise group dictionary form within the task info, fetches that 3983 user's extension info for that group and returns a 3984 `datetime.datetime` object representing that user's deadline for 3985 that exercise group. 3986 """ 3987 # Standard extension hours 3988 standard_ext_hrs = task_info.get("extension_hours", 24) 3989 3990 # Get extension value 3991 extension = storage.get_extension( 3992 course, 3993 semester, 3994 username, 3995 egroup["group"], 3996 "initial" 3997 ) 3998 if extension is None: 3999 flask.flash( 4000 "Error fetching exercise extension info (treating as 0)." 4001 ) 4002 extension = 0 4003 elif extension is True: 4004 extension = standard_ext_hrs 4005 elif extension is False: 4006 extension = 0 4007 elif not isinstance(extension, (int, float)): 4008 flask.flash( 4009 "Ignoring invalid initial extension value '{}'".format( 4010 extension 4011 ) 4012 ) 4013 extension = 0 4014 4015 # Get timely time 4016 timely_by = potluck.time_utils.task_time__time( 4017 task_info, 4018 egroup["timely"], 4019 default_time_of_day=task_info.get( 4020 "default_release_time_of_day", 4021 "23:59" 4022 ) 4023 ) 4024 4025 # Apply the extension 4026 return timely_by + datetime.timedelta(hours=extension) 4027 4028 4029def fetch_all_best_outcomes( 4030 course, 4031 semester, 4032 username, 4033 task_info, 4034 only_groups=None, 4035 only_exercises=None 4036): 4037 """ 4038 Fetches a dictionary mapping each individual exercise ID to the best 4039 outcome for that exercise (omitting keys for exercise IDs where the 4040 user hasn't submitted anything yet). 4041 4042 If `only_groups` is supplied, it should be a sequence of groups to 4043 fetch outcomes for (instead of 'all groups') and if `only_exercises` 4044 is supplied, it should be a sequence of specific exercises to fetch 4045 outcomes for. When both are given, exercises not in one of the 4046 specified groups will not be fetched. 4047 """ 4048 outcomes = {} 4049 use_groups = task_info["exercises"] 4050 if only_groups is not None: 4051 use_groups = [ 4052 group 4053 for group in use_groups 4054 if group["group"] in only_groups 4055 ] 4056 for egroup in use_groups: 4057 deadline = get_exercise_deadline( 4058 course, 4059 semester, 4060 username, 4061 task_info, 4062 egroup 4063 ) 4064 for ex in egroup["exercises"]: 4065 eid = ex["id"] 4066 # Filter by eid if we were told to only do some 4067 if only_exercises is not None and eid not in only_exercises: 4068 continue 4069 ebest = storage.fetch_best_outcomes( 4070 course, 4071 semester, 4072 username, 4073 eid, 4074 deadline, 4075 usual_config_value( 4076 "LATE_EXERCISE_CREDIT_FRACTION", 4077 task_info, 4078 exercise=eid, 4079 default=0.5 4080 ) 4081 ) 4082 if ebest is not None: 4083 outcomes[eid] = ebest 4084 4085 return outcomes 4086 4087 4088def amend_exercises(course, semester, task_info, outcomes, username): 4089 """ 4090 Amends the exercises in the provided task info based on the given 4091 outcomes. It adds time + submission statuses for each exercise, as 4092 well as extension info based on the provided 4093 course/semester/username. 4094 4095 Each exercise group will gain a "phase" slot with a string 4096 indicating which time phase it's in (see the "exercises" entry 4097 above). Each group will also gain an "extension" slot that holds a 4098 number specifying how many hours of extension the user has been 4099 granted for that exercise group, and a "timely_by" slot that holds a 4100 datetime object indicating the deadline with any extension factored 4101 in. 4102 4103 Also, each individual exercise which has been submitted will 4104 gain the following slots: 4105 4106 - a "status" slot with a submission status string (also see above). 4107 - a "credit" slot with a numerical credit value, or None for 4108 unsubmitted exercises. 4109 - a "group_credit" slot with a numerical credit value towards group 4110 credit, which is usually the same as "credit". 4111 4112 Finally, the exercise groups will gain a "credit_fraction" slot 4113 indicating what fraction of credit has been received, and a "status" 4114 slot aggregating the statuses of its components, depending on its 4115 phase. This group status is determined as follows: 4116 4117 1. If all of the exercises in the group are complete, it counts as 4118 "perfect". 4119 2. If at least 80% (configurable as `EXERCISE_GROUP_THRESHOLD`) of 4120 the exercises in the group are complete (counting 4121 partially-complete exercises as 1/2) then the group counts as 4122 "complete". 4123 3. If at least `EXERCISE_GROUP_PARTIAL_THRESHOLD` (default 2/5) but 4124 less than the `EXERCISE_GROUP_THRESHOLD` fraction of the 4125 exercises are complete, and the phase is "due" then the status 4126 will be "partial". 4127 4. If a smaller fraction than `EXERCISE_GROUP_PARTIAL_THRESHOLD` of 4128 the exercises are complete (still counting partial completions 4129 as 1/2) and the phase is "due" then the status will be 4130 "incomplete". 4131 5. If the status would be less than "complete" but the phase is 4132 "released" instead of "due" then the status will be "pending" 4133 (including when zero exercises have been submitted yet). 4134 6. If the phase is "prerelease" then the status will be "unreleased" 4135 unless submissions to one or more exercises have already 4136 happened, in which case its status will be one of "pending", 4137 "complete", or "perfect" depending on how many exercises are 4138 complete. 4139 4140 Note: an empty outcomes dictionary may be provided if you only care 4141 about setting group phase and extension info and are willing to let 4142 credit info be inaccurate. 4143 """ 4144 # Get current time: 4145 if "PAUSE_AT" in task_info and task_info["PAUSE_AT"]: 4146 now = potluck.time_utils.task_time__time( 4147 task_info, 4148 task_info["PAUSE_AT"] 4149 ) 4150 else: 4151 now = potluck.time_utils.now() 4152 4153 # Standard extension hours 4154 standard_ext_hrs = task_info.get("extension_hours", 24) 4155 4156 # Amend groups and exercises 4157 for group in task_info.get("exercises", []): 4158 # Get extension value 4159 extension = storage.get_extension( 4160 course, 4161 semester, 4162 username, 4163 group["group"], 4164 "initial" 4165 ) 4166 if extension is None: 4167 flask.flash( 4168 "Error fetching exercise extension info (treating as 0)." 4169 ) 4170 extension = 0 4171 elif extension is True: 4172 extension = standard_ext_hrs 4173 elif extension is False: 4174 extension = 0 4175 elif not isinstance(extension, (int, float)): 4176 flask.flash( 4177 "Ignoring invalid initial extension value '{}'".format( 4178 extension 4179 ) 4180 ) 4181 extension = 0 4182 # Now 'extension' is a number so we store it 4183 group["extension"] = extension 4184 4185 # Get release + timely times 4186 release_at = potluck.time_utils.task_time__time( 4187 task_info, 4188 group["release"], 4189 default_time_of_day=task_info.get( 4190 "default_release_time_of_day", 4191 "23:59" 4192 ) 4193 ) 4194 timely_by = potluck.time_utils.task_time__time( 4195 task_info, 4196 group["timely"], 4197 default_time_of_day=task_info.get( 4198 "default_release_time_of_day", 4199 "23:59" 4200 ) 4201 ) 4202 4203 # Apply the extension 4204 timely_by += datetime.timedelta(hours=extension) 4205 4206 # Store calculated deadline 4207 group["timely_by"] = timely_by 4208 4209 # Figure out and apply time phase to group 4210 if now < release_at: 4211 phase = "prerelease" 4212 elif now < timely_by: 4213 phase = "released" 4214 else: 4215 phase = "due" 4216 group["phase"] = phase 4217 4218 # Tracks exercise credit & max for all exercises in the group 4219 group_credit = 0 4220 group_max = 0 4221 4222 # Make allowances for old format 4223 elist = group["exercises"] 4224 if isinstance(elist, dict): 4225 for eid in elist: 4226 elist[eid]['id'] = eid 4227 elist = list(elist.values()) 4228 4229 # Consider each individual exercise in the group 4230 for einfo in elist: 4231 eid = einfo['id'] 4232 # Add to max credit 4233 group_max += 1 4234 4235 # Get info and outcomes 4236 outcomes_here = outcomes.get(eid, {}) 4237 einfo["status"] = outcomes_here.get('status', "unsubmitted") 4238 einfo["on_time"] = outcomes_here.get('on_time', True) 4239 einfo["credit"] = outcomes_here.get('credit', None) 4240 einfo["group_credit"] = outcomes_here.get( 4241 'group_credit', 4242 # Backup in case we're dealing with older data 4243 outcomes_here.get("credit", 0) 4244 ) 4245 4246 # Accumulate credit across the whole group 4247 group_credit += einfo["group_credit"] or 0 4248 4249 # Now that we know the exercise outcomes for each exercise in 4250 # this group, calculate a group status based on the exercise 4251 # outcomes and the group phase. 4252 credit_fraction = group_credit / float(group_max) 4253 if credit_fraction == 1: 4254 status = "perfect" 4255 elif credit_fraction >= usual_config_value( 4256 "EXERCISE_GROUP_THRESHOLD", 4257 task_info, 4258 exercise=group["group"], 4259 default=0.8 4260 ): 4261 status = "complete" 4262 elif credit_fraction >= usual_config_value( 4263 "EXERCISE_GROUP_PARTIAL_THRESHOLD", 4264 task_info, 4265 exercise=group["group"], 4266 default=0.4 4267 ): 4268 status = "partial" 4269 else: 4270 status = "incomplete" 4271 4272 if ( 4273 phase in ("prerelease", "released") 4274 and status not in ("perfect", "complete") 4275 ): 4276 status = "pending" 4277 4278 if phase == "prerelease" and group_credit == 0: 4279 status = "unreleased" 4280 4281 group["status"] = status 4282 group["credit_fraction"] = credit_fraction 4283 4284 # Look up any override and apply it 4285 override = storage.get_egroup_override( 4286 course, 4287 semester, 4288 username, 4289 group["group"] 4290 ) 4291 if override is not None: 4292 if override["override"]: 4293 group["credit_fraction"] = override["override"] 4294 if override["status"]: 4295 group["status"] = override["status"] 4296 if isinstance(override["note"], str) and override["note"] != '': 4297 group["note"] = potluck.render.render_markdown( 4298 override["note"] 4299 ) 4300 4301 4302def set_concept_statuses(concepts, task_info, outcomes): 4303 """ 4304 Updates the provided concepts list (whose elements are concept 4305 dictionaries which might have "facets" slots that have sub-concepts 4306 in them) with status info based on the given (amended) task info and 4307 exercise outcomes provided. You must call `amend_task_info`, 4308 `amend_exercises`, and `augment_concepts` before calling this 4309 function. 4310 4311 The concepts dictionary is directly augmented so that each concept 4312 has the following slots: 4313 - "status" holding an aggregate status string 4314 - "outcomes" holding a list of relevant outcomes tuples Each outcome 4315 tuple has an exercise/task-id, a numeric (0-1) outcome value, 4316 and string ('task', 'exercise', or 'outcome') specifying whether 4317 it's an individual outcome or an aggregate outcome from a task 4318 or exercise. 4319 - "exercises" holding a dictionary mapping exercise IDs to 4320 phase/status string pairs for exercises which are relevant to 4321 this concept. The first string represents phase as one of 4322 "prerelease", "released", or "due" to specify the exercise's 4323 release status, and the second is one of "complete", "partial", 4324 "incomplete", or "unsubmitted" to specify the exercise's 4325 submission status. Exercise IDs won't even be in this list if 4326 they haven't been submitted yet. 4327 """ 4328 4329 # Attach outcomes to concepts based on exercise info 4330 for group in task_info.get("exercises", []): 4331 phase = group["phase"] 4332 4333 # Make allowances for old format 4334 elist = group["exercises"] 4335 if isinstance(elist, dict): 4336 for eid in elist: 4337 elist[eid]['id'] = eid 4338 elist = list(elist.values()) 4339 4340 # Consider each individual exercise in the group 4341 for einfo in elist: 4342 eid = einfo['id'] 4343 # Get info, tag, and outcomes 4344 etag = "{}:{}".format(group, eid) 4345 outcomes_here = outcomes.get(eid, {}).get('outcomes', None) 4346 4347 submission_status = einfo["status"] 4348 ecredit = einfo["credit"] 4349 4350 # Note submission status 4351 einfo["status"] = submission_status 4352 4353 # First apply binary credit to each concept for per-outcome 4354 # concepts, and associate per-outcome exercise statuses as 4355 # well. 4356 for i, entry in enumerate(einfo.get("per_outcome", [])): 4357 # Attach individual outcomes to associated per-outcome 4358 # concepts 4359 if ecredit is not None: 4360 # Replace True/False with 1/0: 4361 outcome = outcomes_here[i][:] 4362 outcome[0] = 1 if outcome[0] else 0 4363 oinfo = ( 4364 '{}#{}'.format(etag, i), 4365 1 if outcome[0] else 0, 4366 "outcome" 4367 ) 4368 # Attach outcome 4369 attach_outcome(concepts, oinfo, entry) 4370 outcome_status = ( 4371 "complete" 4372 if outcome[0] 4373 else "incomplete" 4374 ) 4375 else: 4376 outcome_status = submission_status 4377 4378 # Note exercise status for these concepts as well, but 4379 # with success/failure based on individual outcomes 4380 note_exercise_status( 4381 concepts, 4382 eid, 4383 (phase, outcome_status), 4384 entry 4385 ) 4386 4387 # Now apply the exercise credit as an outcome to each 4388 # concept that's set at the exercise level, plus apply 4389 # individual outcomes to their associated per-outcome 4390 # concepts. 4391 for concept_path in einfo.get("concepts", []): 4392 # Note status, potentially overriding per-outcome 4393 # statuses that have already been attached 4394 note_exercise_status( 4395 concepts, 4396 eid, 4397 (phase, submission_status), 4398 concept_path 4399 ) 4400 4401 # Attach exercise-level outcome to exercise-level 4402 # concepts 4403 if ecredit is not None: 4404 try: 4405 attach_outcome( 4406 concepts, 4407 ( 4408 "exercise@{}".format(etag), 4409 ecredit, 4410 "exercise" 4411 ), 4412 concept_path 4413 ) 4414 except ValueError: 4415 raise ValueError( 4416 ( 4417 "In group '{}' exercise '{}' references" 4418 " concept '{}' but that concept doesn't" 4419 " exist." 4420 ).format(group['group'], eid, concept_path) 4421 ) 4422 4423 # TODO: Attach outcomes from project tasks! 4424 4425 # Set concept statuses based on attached outcomes 4426 # TODO: THIS 4427 4428 4429def all_parents(concepts, concept_path): 4430 """ 4431 Yields each parent of the given concept path, including the target 4432 concept itself, in depth-first order and processing each concept 4433 only once even if parent-loops occur. 4434 4435 Raises a `ValueError` if the target concept cannot be found. 4436 """ 4437 concept = lookup_concept(concepts, concept_path.split(':')) 4438 if concept is None: 4439 raise ValueError( 4440 ( 4441 "Couldn't find parents of concept '{}': that concept" 4442 " does not exist." 4443 ).format(concept_path) 4444 ) 4445 4446 # Process all parents using a stack 4447 seen = set() 4448 stack = [ concept ] 4449 while len(stack) > 0: 4450 this_concept = stack.pop() 4451 4452 # Skip if we've already processed this concept 4453 if this_concept["path"] in seen: 4454 continue 4455 4456 # If we didn't skip, note that we are going to process it 4457 seen.add(this_concept["path"]) 4458 4459 # And yield it 4460 yield this_concept 4461 4462 # Extend stack to include each non-None parent concept 4463 stack.extend( 4464 filter(lambda x: x, this_concept["parents"].values()) 4465 ) 4466 4467 4468def note_exercise_status(concepts, eid, exercise_status, concept_path): 4469 """ 4470 Requires an augmented concepts network, an exercise ID, an exercise 4471 status pair (a tuple containing a time status and a submission 4472 status as strings), and a concept path. 4473 4474 Adds to the "exercises" slot for the specified concept to include 4475 the given exercise status under the given exercise ID, overwriting 4476 any previously-set status. 4477 4478 Applies to parent concepts as well. 4479 4480 Flashes a warning if the target concept cannot be found. 4481 """ 4482 try: 4483 parents = list(all_parents(concepts, concept_path)) 4484 except ValueError as e: 4485 flask.flash( 4486 "Error recording exercise status: {}".format(e) 4487 ) 4488 parents = [] 4489 4490 for concept in parents: 4491 concept.setdefault("exercises", {})[eid] = exercise_status 4492 4493 4494def attach_outcome(concepts, outcome_info, concept_path): 4495 """ 4496 Requires an augmented concepts network and an outcome info tuple (a 4497 string naming the outcome plus a number from 0-1 specifying whether 4498 the outcome indicates success, failure, or some kind of partial 4499 success). 4500 4501 Also needs a concept path specifying the concept to which the 4502 outcome applies. Adds the outcome to the list of outcomes relevant 4503 to the target concept, plus the lists for each concept that's a 4504 parent of the target concept. 4505 4506 Flashes a warning message if the target concept cannot be found. 4507 """ 4508 try: 4509 parents = list(all_parents(concepts, concept_path)) 4510 except ValueError as e: 4511 flask.flash( 4512 "Error attaching exercise outcome: {}".format(e) 4513 ) 4514 parents = [] 4515 4516 for concept in parents: 4517 # Add this outcome to the outcomes list for this concept, 4518 # creating it if it hasn't already been created. 4519 concept.setdefault("outcomes", []).append(outcome_info) 4520 4521 4522def augment_concepts(concepts): 4523 """ 4524 Takes a concepts list (where each entry is a concept dictionary with 4525 an 'id', a 'desc', and possibly 'facets' containing a 4526 sub-concepts-list, OR has just a 'ref' key naming the id-path to a 4527 different concept). Augments that concepts list by replacing all 4528 'ref' entries with actual object references to the named concept, 4529 and by adding the following keys to each non-reference concept: 4530 4531 - 'path': The full reference path for this concept from a top-level 4532 concept, using home concepts over other references to find a way 4533 to the top level. 4534 - 'parents': a dictionary mapping full-id-path-strings to actual 4535 concept dictionaries, with one entry for each concept that 4536 includes this one as a facet. Will be an empty dictionary for 4537 concepts at the top-level that aren't referenced anywhere. If a 4538 concept is at the top level or referenced there, this dictionary 4539 will have a special entry with key `None` and value `None`. 4540 - 'home': A concept dictionary for the natural parent of this 4541 concept: the one parent which included it directly instead of as 4542 a reference. Will be `None` for top-level concepts, including 4543 ones that are referenced somewhere (to avoid this, you can make 4544 a reference at the top level and place the concept you want to 4545 pull up within the place you'd otherwise reference it). 4546 4547 If something is contradictory (e.g., a named reference concept 4548 doesn't exist) a `ValueError` will be raised. Note that all 4549 references must use canonical paths; they cannot 'go through' other 4550 references. 4551 """ 4552 # Create a stack for processing the recursive entries. Each concept 4553 # entry is paired with its natural parent and the index among that 4554 # parent's facets it exists at. 4555 stack = [(concept, None, None) for concept in concepts] 4556 4557 # Continue until we run out of concepts to process 4558 while len(stack) > 0: 4559 # Get the concept + parent + index combo to process 4560 (concept, home, facet_index) = stack.pop() 4561 4562 # Are we a reference? 4563 if 'ref' not in concept: 4564 # Not a reference; augment things & stack up facets 4565 4566 # Set the home for this concept 4567 concept['home'] = home 4568 4569 # Create an empty parents dictionary, or retrieve an 4570 # existing dictionary (possibly created due to a 4571 # previously-processed reference) 4572 parents = concept.setdefault('parents', {}) 4573 4574 # Set path and update parents differently based on whether 4575 # we're at the top level or not 4576 if home is None: 4577 concept['path'] = concept["id"] 4578 parents[None] = None 4579 else: 4580 concept['path'] = home["path"] + ':' + concept["id"] 4581 parents[home['path']] = home 4582 4583 for i, facet in enumerate(concept.get('facets', [])): 4584 stack.append((facet, concept, i)) 4585 4586 else: # if we *are* a reference... 4587 referent = lookup_concept(concepts, concept['ref'].split(':')) 4588 4589 if referent is None: 4590 raise ValueError( 4591 ( 4592 "At '{}', one facet is a reference to '{}', but" 4593 " that concept does not exist. (Note: all" 4594 " references must be via canonical paths, i.e.," 4595 " references may not go through other" 4596 " references.)" 4597 ).format(home['path'], concept['ref']) 4598 ) 4599 4600 # We need to replace ourselves with a real object reference, 4601 # unless we're at the top level 4602 if home is not None: 4603 home['facets'][facet_index] = referent 4604 4605 # We also need to update the parents dictionary of the 4606 # referent to include the parent concept here. 4607 referent.setdefault('parents', {})[home['path']] = home 4608 else: 4609 # Add a 'None' key if this reference is at the top level 4610 referent.setdefault('parents', {})[None] = None 4611 4612 # Note that we *don't* add facets to the stack here! They'll 4613 # be added later after the natural copy of the referent is 4614 # processed. 4615 4616 4617def lookup_concept(concepts, concept_names): 4618 """ 4619 Based on a sequence of concept-name strings, returns the associated 4620 concept dictionary from the given top-level concepts list. This will 4621 only be able to find concepts via their natural parents, unless the 4622 concepts list has been augmented. 4623 4624 Returns `None` if there is no such concept. 4625 """ 4626 first = concept_names[0] 4627 match = None 4628 for concept in concepts: 4629 if concept["id"] == first: 4630 match = concept 4631 break 4632 4633 if match is None: 4634 return None 4635 elif len(concept_names) == 1: 4636 return match 4637 else: 4638 return lookup_concept(match.get('facets', []), concept_names[1:]) 4639 4640 4641def percentile(dataset, pct): 4642 """ 4643 Computes the nth percentile of the dataset by a weighted average of 4644 the two items on either side of that fractional index within the 4645 dataset. pct must be a number between 0 and 100 (inclusive). 4646 4647 Returns None when given an empty dataset, and always returns the 4648 singular item in the dataset when given a dataset of length 1. 4649 """ 4650 fr = pct / 100.0 4651 if len(dataset) == 1: 4652 return dataset[0] 4653 elif len(dataset) == 0: 4654 return None 4655 srt = sorted(dataset) 4656 fridx = fr * (len(srt) - 1) 4657 idx = int(fridx) 4658 if idx == fridx: 4659 return srt[idx] # integer index -> no averaging 4660 leftover = fridx - idx 4661 first = srt[idx] 4662 second = srt[idx + 1] # legal index because we can't have hit the end 4663 return first * (1 - leftover) + second * leftover 4664 4665 4666def get_feedback_pr_and_task( 4667 task_info, 4668 course, 4669 semester, 4670 user, 4671 phase, 4672 prid, 4673 taskid 4674): 4675 """ 4676 Given a task_info object and a particular 4677 course/semester/user/phase/project/task we're interested in, 4678 extracts and augments pr and task objects to make them ready for 4679 rendering as feedback, returning a tuple of both. 4680 4681 Returns a ValueError object in cases where the requested 4682 project/task doesn't exist. 4683 """ 4684 4685 # Extract pr & task objects 4686 try: 4687 pr = get_pr_obj(task_info, prid) 4688 except ValueError as e: 4689 return e 4690 4691 try: 4692 task = get_task_obj(task_info, pr, taskid) 4693 except ValueError as e: 4694 return e 4695 4696 # Add status & time remaining info to project and objects 4697 amend_project(course, semester, task_info, pr, user) 4698 amend_task(course, semester, task_info, prid, task, user, phase) 4699 4700 # Get full feedback for the task in question if it's available 4701 task["feedback"] = storage.get_feedback( 4702 course, 4703 semester, 4704 task_info, 4705 user, 4706 phase, 4707 pr["id"], 4708 task["id"] 4709 ) 4710 # Get HTML feedback as well 4711 task["feedback_html"] = storage.get_feedback_html( 4712 course, 4713 semester, 4714 task_info, 4715 user, 4716 phase, 4717 pr["id"], 4718 task["id"] 4719 ) 4720 if task["feedback"]["status"] != "missing": 4721 task["submitted"] = True 4722 potluck.render.augment_report(task["feedback"]) 4723 4724 return pr, task 4725 4726 4727def get_evaluation_info( 4728 course, 4729 semester, 4730 target_user, 4731 phase, 4732 prid, 4733 taskid 4734): 4735 """ 4736 Fetches notes and override info for the given submission, and returns 4737 a tuple including the notes markdown source, the notes rendered HTML, 4738 the grade override value, and the timeliness override value. 4739 4740 The grade override will be an empty string if no override is active, 4741 and should be a floating-point value otherwise except in cases where 4742 a non-numeric value was set. 4743 4744 The timeliness override is similar, and should only ever be set for 4745 the 'initial' phase, since it applies across all phases. 4746 4747 The notes and notes HTML strings will be empty strings if no notes 4748 have been set. 4749 """ 4750 # Fetch raw eval info dict 4751 evaluation = storage.fetch_evaluation( 4752 course, 4753 semester, 4754 target_user, 4755 phase, 4756 prid, 4757 taskid 4758 ) 4759 4760 if evaluation is None: 4761 return '', '', '', '' 4762 4763 # Extract notes and grade override from stored info 4764 notes = evaluation.get("notes", "") 4765 override = evaluation.get("override") 4766 if override is None: 4767 override = "" 4768 else: 4769 try: 4770 override = float(override) 4771 except Exception: 4772 pass 4773 4774 timeliness = evaluation.get("timeliness") 4775 if timeliness is None: 4776 timeliness = "" 4777 else: 4778 try: 4779 timeliness = float(timeliness) 4780 except Exception: 4781 pass 4782 4783 # Render notes as HTML 4784 notes_html = potluck.render.render_markdown(notes) 4785 4786 return notes, notes_html, override, timeliness 4787 4788 4789#----------------# 4790# Time functions # 4791#----------------# 4792 4793ONE_MINUTE = 60 4794ONE_HOUR = ONE_MINUTE * 60 4795ONE_DAY = ONE_HOUR * 24 4796ONE_WEEK = ONE_DAY * 7 4797 4798 4799def project_status_now( 4800 username, 4801 task_info, 4802 project_obj, 4803 extensions=(False, False) 4804): 4805 """ 4806 Returns the current state of the given project object (also needs 4807 the task info object and the username). If "PAUSE_AT" is set in the 4808 task object and non-empty (see set_pause_time), that moment, not the 4809 current time, will be used. Extensions must contain two values (one 4810 for the initial phase and one for the revision phase). They may each 4811 be False for no extension, True for the default extension, or an 4812 integer number of hours. Those hours will be added to the effective 4813 initial and revision deadlines. 4814 4815 Returns a dictionary with 'state', 'initial-extension', 4816 'revision-extension', 'release', 'due', 'reviewed', and 'finalized', 4817 keys. Each of the 'release', 'due', 'reviewed', and 'finalized' keys 4818 will be a sub-dictionary with the following keys: 4819 4820 - 'at': A dateimte.datetime object specifying absolute timing. 4821 - 'at_str': A string representation of the above. 4822 - 'until': A datetime.timedelta representing time until the event (will 4823 be negative afterwards). 4824 - 'until_str': A string representation of the above. 4825 4826 Each of the sub-values will be none if the project doesn't have a 4827 deadline set. 4828 4829 The 'state' value will be one of: 4830 4831 - "unreleased": This project hasn't yet been released; don't display 4832 any info about it. 4833 - "released": This project has been released and isn't due yet. 4834 - "under_review": This project's due time has passed, but the feedback 4835 review period hasn't expired yet. 4836 - "revisable": This project's due time is past, and the review period has 4837 expired, so full feedback should be released, but revisions may 4838 still be submitted. 4839 - "final": This project's due time is past, the review period has 4840 expired, and the revision period is also over, so full feedback 4841 is available, and no more submissions will be accepted. 4842 - "unknown": This project doesn't have a due date. The 4843 seconds_remaining value will be None. 4844 4845 The 'initial_extension' and 'revision_extension' will both be numbers 4846 specifying how many hours of extension were granted (these numbers 4847 are already factored into the deadline information the status 4848 contains). These numbers will be 0 for students who don't have 4849 extensions. 4850 """ 4851 # Get extension/revision durations and grace period from task info: 4852 standard_ext_hrs = task_info.get("extension_hours", 24) 4853 review_hours = task_info.get("review_hours", 24) 4854 grace_mins = task_info.get("grace_minutes", 0) 4855 revision_hours = task_info.get("revision_hours", 72) 4856 4857 # Get project-specific review/grace/revision info if it exists 4858 review_hours = project_obj.get("review_hours", review_hours) 4859 grace_mins = project_obj.get("grace_minutes", grace_mins) 4860 revision_hours = project_obj.get("revision_hours", revision_hours) 4861 4862 # Figure out extension amounts 4863 initial_extension = 0 4864 if extensions[0] is True: 4865 initial_extension = standard_ext_hrs 4866 elif isinstance(extensions[0], (int, float)): 4867 initial_extension = extensions[0] 4868 elif extensions[0] is not False: 4869 flask.flash( 4870 "Ignoring invalid initial extension value '{}'".format( 4871 extensions[0] 4872 ) 4873 ) 4874 4875 revision_extension = 0 4876 if extensions[1] is True: 4877 revision_extension = standard_ext_hrs 4878 elif isinstance(extensions[1], (int, float)): 4879 revision_extension = extensions[1] 4880 elif extensions[1] is not False: 4881 flask.flash( 4882 "Ignoring invalid revision extension value '{}'".format( 4883 extensions[1] 4884 ) 4885 ) 4886 4887 # The default result 4888 result = { 4889 'state': "unknown", 4890 'release': { 4891 'at': None, 4892 'at_str': 'unknown', 4893 'until': None, 4894 'until_str': 'at some point (not yet specified)' 4895 }, 4896 'due': { 4897 'at': None, 4898 'at_str': 'unknown', 4899 'until': None, 4900 'until_str': 'at some point (not yet specified)' 4901 }, 4902 'reviewed': { 4903 'at': None, 4904 'at_str': 'unknown', 4905 'until': None, 4906 'until_str': 'at some point (not yet specified)' 4907 }, 4908 'finalized': { 4909 'at': None, 4910 'at_str': 'unknown', 4911 'until': None, 4912 'until_str': 'at some point (not yet specified)' 4913 }, 4914 'initial_extension': 0, 4915 'revision_extension': 0, 4916 } 4917 4918 # Save extension info 4919 result['initial_extension'] = initial_extension or 0 4920 result['revision_extension'] = revision_extension or 0 4921 4922 # Get current time: 4923 if "PAUSE_AT" in task_info and task_info["PAUSE_AT"]: 4924 now = potluck.time_utils.task_time__time( 4925 task_info, 4926 task_info["PAUSE_AT"] 4927 ) 4928 else: 4929 now = potluck.time_utils.now() 4930 4931 # Get release date/time: 4932 release_at = project_obj.get("release", None) 4933 # if None, we assume release 4934 if release_at is not None: 4935 release_at = potluck.time_utils.task_time__time( 4936 task_info, 4937 release_at, 4938 default_time_of_day=task_info.get( 4939 "default_release_time_of_day", 4940 "23:59" 4941 ) 4942 ) 4943 # Fill in release info 4944 result['release']['at'] = release_at 4945 result['release']['at_str'] = potluck.time_utils.fmt_datetime( 4946 release_at 4947 ) 4948 until_release = release_at - now 4949 result['release']['until'] = until_release 4950 result['release']['until_str'] = fuzzy_time( 4951 until_release.total_seconds() 4952 ) 4953 4954 # Get due date/time: 4955 due_at = project_obj.get("due", None) 4956 if due_at is None: 4957 # Return empty result 4958 return result 4959 else: 4960 due_at = potluck.time_utils.task_time__time( 4961 task_info, 4962 due_at, 4963 default_time_of_day=task_info.get( 4964 "default_due_time_of_day", 4965 "23:59" 4966 ) 4967 ) 4968 review_end = due_at + datetime.timedelta(hours=review_hours) 4969 4970 due_string = potluck.time_utils.fmt_datetime(due_at) 4971 4972 base_deadline = due_at 4973 4974 # Add extension hours: 4975 if initial_extension > 0: 4976 due_at += datetime.timedelta(hours=initial_extension) 4977 due_string = potluck.time_utils.fmt_datetime(due_at) + ( 4978 ' <span class="extension_taken">' 4979 + '(after accounting for your {}‑hour extension)' 4980 + '</span>' 4981 ).format(initial_extension) 4982 4983 grace_deadline = due_at + datetime.timedelta(minutes=grace_mins) 4984 4985 # Fill in due info 4986 result['due']['at'] = due_at 4987 result['due']['at_str'] = due_string 4988 until_due = due_at - now 4989 result['due']['until'] = until_due 4990 result['due']['until_str'] = fuzzy_time(until_due.total_seconds()) 4991 4992 # Fill in review info 4993 result['reviewed']['at'] = review_end 4994 result['reviewed']['at_str'] = potluck.time_utils.fmt_datetime( 4995 review_end 4996 ) 4997 until_reviewed = review_end - now 4998 result['reviewed']['until'] = until_reviewed 4999 result['reviewed']['until_str'] = fuzzy_time( 5000 until_reviewed.total_seconds() 5001 ) 5002 5003 # Get final date/time: 5004 # Note: any extension to the initial deadline is ignored. A separate 5005 # revision extension should be issued when an initial extension eats 5006 # up too much of the revision period. 5007 final_at = base_deadline + datetime.timedelta( 5008 hours=review_hours + revision_hours 5009 ) 5010 5011 final_string = potluck.time_utils.fmt_datetime(final_at) 5012 5013 # Add extension hours: 5014 if revision_extension > 0: 5015 final_at += datetime.timedelta(hours=revision_extension) 5016 final_string = potluck.time_utils.fmt_datetime(final_at) + ( 5017 ' <span class="extension_taken">' 5018 + '(after accounting for your {}‑hour extension)' 5019 + '</span>' 5020 ).format(revision_extension) 5021 5022 grace_final = final_at + datetime.timedelta(minutes=grace_mins) 5023 5024 # Fill in finalization info 5025 result['finalized']['at'] = final_at 5026 result['finalized']['at_str'] = final_string 5027 until_final = final_at - now 5028 result['finalized']['until'] = until_final 5029 result['finalized']['until_str'] = fuzzy_time(until_final.total_seconds()) 5030 5031 # Check release time: 5032 if release_at and now < release_at: 5033 result['state'] = "unreleased" 5034 # Passed release_at point: check if it's due or not 5035 elif now < grace_deadline: 5036 # Note time-remaining ignores grace period and may be negative 5037 result['state'] = "released" 5038 # Passed due_at point; check if it's still under review: 5039 elif now < review_end: 5040 result['state'] = "under_review" 5041 # Passed review period: are revisions still being accepted? 5042 elif now < grace_final: 5043 result['state'] = "revisable" 5044 # Passed review period: it's final 5045 else: 5046 result['state'] = "final" 5047 5048 return result 5049 5050 5051#--------------------# 5052# Filename functions # 5053#--------------------# 5054 5055def get_submission_filename( 5056 course, 5057 semester, 5058 task_info, 5059 username, 5060 phase, 5061 prid, 5062 taskid 5063): 5064 """ 5065 Returns the filename for the user's submission for a given 5066 phase/project/task. Raises a ValueError if the project or task 5067 doesn't exit. 5068 5069 TODO: Do we just do zip files for multi-file tasks? How is that 5070 handled? 5071 """ 5072 pr = get_pr_obj(task_info, prid) 5073 task = get_task_obj(task_info, pr, taskid) 5074 5075 return safe_join( 5076 storage.submissions_folder(course, semester), 5077 username, 5078 "{}_{}_{}".format( 5079 prid, 5080 phase, 5081 task["target"] 5082 ) 5083 ) 5084 5085 5086#---------------# 5087# Jinja support # 5088#---------------# 5089 5090_sorted = sorted 5091 5092 5093@app.template_filter() 5094def sorted(*args, **kwargs): 5095 """ 5096 Turn builtin sorted into a template filter... 5097 """ 5098 return _sorted(*args, **kwargs) 5099 5100 5101@app.template_filter() 5102def fuzzy_time(seconds): 5103 """ 5104 Takes a number of seconds and returns a fuzzy time value that shifts 5105 units (up to weeks) depending on how many seconds there are. Ignores 5106 the sign of the value. 5107 """ 5108 if seconds < 0: 5109 seconds = -seconds 5110 5111 weeks = seconds / ONE_WEEK 5112 seconds %= ONE_WEEK 5113 days = seconds / ONE_DAY 5114 seconds %= ONE_DAY 5115 hours = seconds / ONE_HOUR 5116 seconds %= ONE_HOUR 5117 minutes = seconds / ONE_MINUTE 5118 seconds %= ONE_MINUTE 5119 if int(weeks) > 1: 5120 if weeks % 1 > 0.75: 5121 return "almost {:.0f} weeks".format(weeks + 1) 5122 else: 5123 return "{:.0f} weeks".format(weeks) 5124 elif int(weeks) == 1: 5125 return "{:.0f} days".format(7 + days) 5126 elif int(days) > 1: 5127 if days % 1 > 0.75: 5128 return "almost {:.0f} days".format(days + 1) 5129 else: 5130 return "{:.0f} days".format(days) 5131 elif int(days) == 1: 5132 return "{:.0f} hours".format(24 + hours) 5133 elif hours > 4: 5134 if hours % 1 > 0.75: 5135 return "almost {:.0f} hours".format(hours + 1) 5136 else: 5137 return "{:.0f} hours".format(hours) 5138 elif int(hours) > 0: 5139 return "{:.0f}h {:.0f}m".format(hours, minutes) 5140 elif minutes > 30: 5141 return "{:.0f} minutes".format(minutes) 5142 else: 5143 return "{:.0f}m {:.0f}s".format(minutes, seconds) 5144 5145 5146@app.template_filter() 5147def timestamp(value): 5148 """ 5149 A filter to display a timestamp. 5150 """ 5151 dt = potluck.time_utils.time_from_timestring(value) 5152 return potluck.time_utils.fmt_datetime(dt) 5153 5154 5155@app.template_filter() 5156def seconds(timedelta): 5157 """ 5158 Converts a timedelta to a floating-point number of seconds. 5159 """ 5160 return timedelta.total_seconds() 5161 5162 5163@app.template_filter() 5164def integer(value): 5165 """ 5166 A filter to display a number as an integer. 5167 """ 5168 if isinstance(value, (float, int)): 5169 return str(round(value)) 5170 else: 5171 return str(value) 5172 5173 5174app.add_template_global(min, name='min') 5175app.add_template_global(max, name='max') 5176app.add_template_global(round, name='round') 5177app.add_template_global(sum, name='sum') 5178app.add_template_global(enumerate, name='enumerate') 5179app.add_template_global(potluck.time_utils.now, name='now') 5180 5181# Custom filename->slug filter from potluck 5182app.template_filter()(potluck.html_tools.fileslug) 5183 5184# Time filters from potluck 5185app.template_filter()(potluck.time_utils.fmt_datetime) 5186 5187 5188@app.template_filter() 5189def a_an(h): 5190 """ 5191 Returns the string 'a' or 'an' where the use of 'a/an' depends on the 5192 first letter of the name of the first digit of the given number, or 5193 the first letter of the given string. 5194 5195 Can't handle everything because it doesn't know phonetics (e.g., 'a 5196 hour' not 'an hour' because 'h' is not a vowel). 5197 """ 5198 digits = str(h) 5199 fd = digits[0] 5200 if fd in "18aeiou": 5201 return 'an' 5202 else: 5203 return 'a' 5204 5205 5206@app.template_filter() 5207def project_combined_grade(project, task_info=None): 5208 """ 5209 Extracts a full combined grade value from a project object. Respects 5210 task weights; fills in zeroes for any missing grades, and grabs the 5211 highest score from each task pool. Includes timeliness points along 5212 with task grades, re-normalizing to be out of the `SCORE_BASIS`. 5213 5214 If `task_info` is provided a `SCORE_BASIS` default may be picked up 5215 from there if the project doesn't define one. If not, only the app 5216 global config can define `SCORE_BASIS` if one isn't specified within 5217 the project itself. 5218 """ 5219 pool_scores = {} 5220 for task in project["tasks"]: 5221 # Get a grade & weight 5222 cg = task_combined_grade(task, task_info) 5223 tp = task_timeliness_points(task, task_info) 5224 tw = task.get("weight", 1) 5225 if cg is None: 5226 cg = 0 5227 5228 new_score = float(cg + tp) # float just in case... 5229 5230 # Figure out this task's pool and update the score for that pool 5231 pool = task_pool(task) 5232 if pool in pool_scores: 5233 old_score, old_weight = pool_scores[pool] 5234 if old_weight != tw: 5235 raise ValueError("Inconsistent weights for pooled tasks!") 5236 if old_score < new_score: 5237 pool_scores[pool] = [new_score, tw] 5238 else: 5239 pool_scores[pool] = [new_score, tw] 5240 5241 score_basis = fallback_config_value( 5242 "SCORE_BASIS", 5243 project, 5244 task_info or {}, 5245 app.config, 5246 DEFAULT_CONFIG 5247 ) 5248 max_score = score_basis + fallback_config_value( 5249 "TIMELINESS_POINTS", 5250 project, 5251 task_info or {}, 5252 app.config, 5253 DEFAULT_CONFIG 5254 ) 5255 weighted_score = sum( 5256 (grade / float(max_score)) * weight 5257 for grade, weight in pool_scores.values() 5258 ) 5259 total_weight = sum(weight for grade, weight in pool_scores.values()) 5260 5261 return score_basis * weighted_score / float(total_weight) 5262 5263 5264@app.template_filter() 5265def uses_pools(project): 5266 """ 5267 Returns True if the project has at least two tasks that are in the same 5268 pool, and False otherwise. 5269 """ 5270 pools = set(task_pool(task) for task in project["tasks"]) 5271 return len(pools) < len(project["tasks"]) 5272 5273 5274@app.template_filter() 5275def task_pool(task): 5276 """ 5277 Grabs the pool for a task, which defaults to the task ID. 5278 """ 5279 return task.get("pool", task["id"]) 5280 5281 5282@app.template_filter() 5283def project_pools(project): 5284 """ 5285 Returns a list of pairs, each containing a pool ID and a colspan 5286 integer for that pool. 5287 """ 5288 seen = set() 5289 result = [] 5290 for task in project["tasks"]: 5291 pool = task_pool(task) 5292 if pool in seen: 5293 continue 5294 else: 5295 seen.add(pool) 5296 result.append((pool, pool_colspan(project, task["id"]))) 5297 return result 5298 5299 5300@app.template_filter() 5301def pool_colspan(project, taskid): 5302 """ 5303 Returns the column span for the pool of the given task in the given 5304 project, assuming that the tasks of the project are displayed in order. 5305 """ 5306 start_at = None 5307 this_task = None 5308 for i in range(len(project["tasks"])): 5309 if project["tasks"][i]["id"] == taskid: 5310 start_at = i 5311 this_task = project["tasks"][i] 5312 break 5313 5314 if start_at is None: 5315 raise ValueError( 5316 "Pset '{}' does not contain a task '{}'.".format( 5317 project["id"], 5318 taskid 5319 ) 5320 ) 5321 5322 this_pool = task_pool(this_task) 5323 span = 1 5324 for task in project["tasks"][i + 1:]: 5325 if task_pool(task) == this_pool: 5326 span += 1 5327 else: 5328 break # span is over 5329 5330 return span 5331 5332 5333@app.template_filter() 5334def task_combined_grade(task, task_info=None): 5335 """ 5336 Extracts the combined grade value between initial/revised/belated 5337 submissions for the given task. Returns a point number, or None if 5338 there is not enough information to establish a grade. 5339 5340 Timeliness points are not included (see `task_timeliness_points`). 5341 5342 If `task_info` is provided, then defaults for things like the score 5343 basis or revision score limits may be pulled from that, but otherwise 5344 either the task itself or the app config determine these. 5345 """ 5346 base_grade = task.get("grade") 5347 options = [] 5348 if base_grade is not None and base_grade != "?": 5349 options.append(float(base_grade)) 5350 5351 rev_task = task.get("revision", {}) 5352 rev_grade = rev_task.get("grade") 5353 if isinstance(rev_grade, (int, float)): 5354 rmax = fallback_config_value( 5355 "REVISION_MAX_SCORE", 5356 task, 5357 task_info or {}, 5358 app.config, 5359 DEFAULT_CONFIG 5360 ) 5361 if rmax is NotFound: 5362 rmax = fallback_config_value( 5363 "SCORE_BASIS", 5364 task, 5365 task_info or {}, 5366 app.config, 5367 DEFAULT_CONFIG 5368 ) 5369 if rmax is NotFound: 5370 rmax = 100 5371 options.append(min(rev_grade, rev_task.get("max_score", rmax))) 5372 5373 belated_task = task.get("belated", {}) 5374 belated_grade = belated_task.get("grade") 5375 if isinstance(belated_grade, (int, float)): 5376 bmax = fallback_config_value( 5377 "BELATED_MAX_SCORE", 5378 task, 5379 task_info or {}, 5380 app.config, 5381 DEFAULT_CONFIG 5382 ) 5383 if bmax is NotFound: 5384 bmax = fallback_config_value( 5385 "SCORE_BASIS", 5386 task, 5387 task_info or {}, 5388 app.config, 5389 DEFAULT_CONFIG 5390 ) 5391 if bmax is NotFound: 5392 bmax = 100 5393 options.append( 5394 min(belated_grade, belated_task.get("max_score", bmax)) 5395 ) 5396 5397 if len(options) > 0: 5398 return max(options) 5399 else: 5400 return None 5401 5402 5403@app.template_filter() 5404def task_timeliness_points(task, task_info=None): 5405 """ 5406 Returns a number indicating how many timeliness points were earned 5407 for submissions to the given task. The `TIMELINESS_POINTS` value 5408 determines how many points are available in total; half of these are 5409 awarded if a submission is made by the initial deadline which earns 5410 at least `TIMELINESS_ATTEMPT_THRESHOLD` points, and the other half 5411 are earned if a submission is made by the revision deadline which 5412 earns at least `TIMELINESS_COMPLETE_THRESHOLD` points. 5413 5414 Config values are pulled from the provided `task_info` object if 5415 there is one; otherwise they come from the task itself or from the 5416 app-wide config, with the `DEFAULT_CONFIG` as a final backup. 5417 5418 A manual override may also have been provided, and is used if so. 5419 5420 TODO: This really needs to be upgraded at some point to respect the 5421 best submission in each phase, rather than just the latest. Students 5422 who accidentally downgrade their evaluation may lose timeliness 5423 points that they really should have earned! 5424 """ 5425 earned = 0 5426 available = fallback_config_value( 5427 "TIMELINESS_POINTS", 5428 task, 5429 task_info or {}, 5430 app.config, 5431 DEFAULT_CONFIG 5432 ) 5433 timely_attempt_threshold = fallback_config_value( 5434 "TIMELINESS_ATTEMPT_THRESHOLD", 5435 task, 5436 task_info or {}, 5437 app.config, 5438 DEFAULT_CONFIG 5439 ) 5440 if timely_attempt_threshold is NotFound: 5441 timely_attempt_threshold = fallback_config_value( 5442 ["EVALUATION_SCORES", "partially complete"], 5443 task, 5444 task_info or {}, 5445 app.config, 5446 DEFAULT_CONFIG 5447 ) 5448 if timely_attempt_threshold is NotFound: 5449 timely_attempt_threshold = 75 5450 completion_threshold = fallback_config_value( 5451 "TIMELINESS_COMPLETE_THRESHOLD", 5452 task, 5453 task_info or {}, 5454 app.config, 5455 DEFAULT_CONFIG 5456 ) 5457 if timely_attempt_threshold is NotFound: 5458 timely_attempt_threshold = fallback_config_value( 5459 ["EVALUATION_SCORES", "almost complete"], 5460 task, 5461 task_info or {}, 5462 app.config, 5463 DEFAULT_CONFIG 5464 ) 5465 if timely_attempt_threshold is NotFound: 5466 timely_attempt_threshold = 85 5467 5468 if available is NotFound: 5469 available = 0 5470 attempt_points = available / 2 5471 if attempt_points == 0: 5472 attempt_points = available / 2.0 5473 completion_points = available - attempt_points 5474 5475 if task.get("timeliness_overridden") and "timeliness" in task: 5476 return task["timeliness"] 5477 5478 initial_grade = task.get("grade") 5479 if initial_grade == "?": 5480 initial_grade = None 5481 elif initial_grade is not None: 5482 initial_grade = float(initial_grade) 5483 if initial_grade >= timely_attempt_threshold: 5484 earned += attempt_points 5485 5486 rev_task = task.get("revision") 5487 has_rev_grade = ( 5488 rev_task 5489 and "grade" in rev_task 5490 and rev_task["grade"] not in (None, "?") 5491 ) 5492 5493 if ( 5494 (initial_grade is not None and initial_grade >= completion_threshold) 5495 or (has_rev_grade and float(rev_task["grade"]) >= completion_threshold) 5496 ): 5497 earned += completion_points 5498 5499 return earned 5500 5501 5502@app.template_filter() 5503def ex_combined_grade(egroup, task_info=None): 5504 """ 5505 Extracts a full combined grade value from an amended exercise group 5506 object. Uses the pre-calculated credit-fraction and adds bonus 5507 points for reaching partial/complete thresholds. 5508 5509 Scores are not rounded (use `grade_string` or the like). 5510 5511 Config values are taken from the given `task_info` object as a backup 5512 to values in the exercise group itself if one is provided. Otherwise 5513 only the app config and `DEFAULT_CONFIG` may specify them. 5514 """ 5515 fraction = egroup["credit_fraction"] 5516 5517 # free credit bump for hitting each threshold 5518 bump = fallback_config_value( 5519 "EXERCISE_GROUP_CREDIT_BUMP", 5520 egroup, 5521 task_info or {}, 5522 app.config, 5523 DEFAULT_CONFIG 5524 ) 5525 if bump is NotFound: 5526 bump = 0.1 5527 5528 partial_threshold = fallback_config_value( 5529 "EXERCISE_GROUP_PARTIAL_THRESHOLD", 5530 egroup, 5531 task_info or {}, 5532 app.config, 5533 DEFAULT_CONFIG 5534 ) 5535 if partial_threshold is NotFound: 5536 partial_threshold = 0.395 5537 5538 full_threshold = fallback_config_value( 5539 "EXERCISE_GROUP_THRESHOLD", 5540 egroup, 5541 task_info or {}, 5542 app.config, 5543 DEFAULT_CONFIG 5544 ) 5545 if full_threshold is NotFound: 5546 full_threshold = 0.795 5547 5548 score_basis = fallback_config_value( 5549 "SCORE_BASIS", 5550 egroup, 5551 task_info or {}, 5552 app.config, 5553 DEFAULT_CONFIG 5554 ) 5555 if score_basis is NotFound: 5556 score_basis = 100 5557 5558 if fraction >= partial_threshold: 5559 fraction += bump 5560 if fraction >= full_threshold: 5561 fraction += bump 5562 5563 # no extra credit 5564 return min(score_basis, score_basis * fraction) 5565 5566 5567@app.template_filter() 5568def grade_string(grade_value, task_info=None, local_info=None): 5569 """ 5570 Turns a grade value (None, or a number) into a grade string (an HTML 5571 string w/ a denominator, or 'unknown'). The rounding preference and 5572 score basis are pulled from the given `task_info` object, or if a 5573 `local_info` object is provided comes from there preferentially. If 5574 neither is available, it will pull from the app config or the 5575 `DEFAULT_CONFIG` values. 5576 """ 5577 basis = fallback_config_value( 5578 "SCORE_BASIS", 5579 local_info or {}, 5580 task_info or {}, 5581 app.config, 5582 DEFAULT_CONFIG 5583 ) 5584 if basis is NotFound: 5585 basis = 100 5586 5587 round_to = fallback_config_value( 5588 "ROUND_SCORES_TO", 5589 local_info or {}, 5590 task_info or {}, 5591 app.config, 5592 DEFAULT_CONFIG 5593 ) 5594 if round_to is NotFound: 5595 round_to = 1 5596 5597 if grade_value is None or not isinstance(grade_value, (int, float)): 5598 return "unknown" 5599 else: 5600 rounded = round(grade_value, round_to) 5601 rdenom = round(basis, round_to) 5602 if int(rounded) == rounded: 5603 rounded = int(rounded) 5604 if int(rdenom) == rdenom: 5605 rdenom = int(rdenom) 5606 return "{} / {}".format(rounded, rdenom) 5607 5608 5609@app.template_filter() 5610def timeliness_string(grade_value, task_info=None, local_info=None): 5611 """ 5612 Turns a timeliness points value (None, or a number) into a timeliness 5613 grade string (an HTML string w/ a denominator, or 'unknown'). 5614 5615 As with `grade_string`, config values are pulled from the given local 5616 and/or task info if provided. 5617 """ 5618 timeliness_points = fallback_config_value( 5619 "TIMELINESS_POINTS", 5620 local_info or {}, 5621 task_info or {}, 5622 app.config, 5623 DEFAULT_CONFIG 5624 ) 5625 if timeliness_points is NotFound: 5626 timeliness_points = 0 5627 5628 round_to = fallback_config_value( 5629 "ROUND_SCORES_TO", 5630 local_info or {}, 5631 task_info or {}, 5632 app.config, 5633 DEFAULT_CONFIG 5634 ) 5635 if round_to is NotFound: 5636 round_to = 1 5637 5638 if grade_value is None or not isinstance(grade_value, (int, float)): 5639 return "unknown" 5640 else: 5641 rounded = round(grade_value, round_to) 5642 rdenom = round(timeliness_points, round_to) 5643 if int(rounded) == rounded: 5644 rounded = int(rounded) 5645 if int(rdenom) == rdenom: 5646 rdenom = int(rdenom) 5647 return "{} / {}".format(rounded, rdenom) 5648 5649 5650@app.template_filter() 5651def shorter_grade(grade_string): 5652 """ 5653 Shortens a grade string. 5654 """ 5655 divis = " / " 5656 if divis in grade_string: 5657 return grade_string[:grade_string.index(divis)] 5658 elif grade_string == "unknown": 5659 return "?" 5660 else: 5661 return "!" 5662 5663 5664@app.template_filter() 5665def grade_category(grade_value, task_info=None, local_info=None): 5666 """ 5667 Categorizes a grade value (0-100 or None). 5668 5669 As with `grade_string`, config values are pulled from the given local 5670 and/or task info if provided. 5671 """ 5672 low_threshold = fallback_config_value( 5673 ["GRADE_THRESHOLDS", "low"], 5674 local_info or {}, 5675 task_info or {}, 5676 app.config, 5677 DEFAULT_CONFIG 5678 ) 5679 if low_threshold is NotFound: 5680 low_threshold = 75 5681 5682 mid_threshold = fallback_config_value( 5683 ["GRADE_THRESHOLDS", "mid"], 5684 local_info or {}, 5685 task_info or {}, 5686 app.config, 5687 DEFAULT_CONFIG 5688 ) 5689 if mid_threshold is NotFound: 5690 mid_threshold = 90 5691 5692 if grade_value is None: 5693 return "missing" 5694 elif grade_value < low_threshold: 5695 return "low" 5696 elif grade_value < mid_threshold: 5697 return "mid" 5698 else: 5699 return "high" 5700 5701 5702@app.template_filter() 5703def timespent(time_spent): 5704 """ 5705 Handles numerical-or-string-or-None time spent values. 5706 """ 5707 if isinstance(time_spent, (int, float)): 5708 if time_spent == 0: 5709 return '-' 5710 elif int(time_spent) == time_spent: 5711 return "{}h".format(int(time_spent)) 5712 else: 5713 return "{}h".format(round(time_spent, 2)) 5714 elif isinstance(time_spent, str): 5715 return time_spent 5716 else: 5717 return "?" 5718 5719 5720@app.template_filter() 5721def initials(full_section_title): 5722 """ 5723 Reduces a full section title potentially including time information 5724 and the word 'Prof.' or 'Professor' to just first initials. 5725 5726 Returns '?' if the input value is `None`. 5727 """ 5728 if full_section_title is None: 5729 return '?' 5730 words = full_section_title.split() 5731 if len(words) > 1: 5732 if words[0] in ('Prof', 'Prof.', 'Professor'): 5733 words.pop(0) 5734 return words[0][0] + words[1][0] 5735 else: 5736 return full_section_title 5737 5738 5739@app.template_filter() 5740def pronoun(pronouns): 5741 """ 5742 Reduces pronoun info to a single pronoun. 5743 """ 5744 test = '/'.join(re.split(r"[^A-Za-z]+", pronouns.lower())) 5745 if test in ( 5746 "she", 5747 "she/her", 5748 "she/hers", 5749 "she/her/hers" 5750 ): 5751 return "she" 5752 elif test in ( 5753 "he", 5754 "he/him", 5755 "he/his", 5756 "he/him/his" 5757 ): 5758 return "he" 5759 elif test in ( 5760 "they", 5761 "they/them", 5762 "them/them/their" 5763 ): 5764 return "they" 5765 else: 5766 return pronouns 5767 5768 5769# Characters that need replacing to avoid breaking strings in JS script 5770# tags 5771_JS_ESCAPES = [ # all characters with ord() < 32 5772 chr(z) for z in range(32) 5773] + [ 5774 '\\', 5775 "'", 5776 '"', 5777 '>', 5778 '<', 5779 '&', 5780 '=', 5781 '-', 5782 ';', 5783 u'\u2028', # LINE_SEPARATOR 5784 u'\u2029', # PARAGRAPH_SEPARATOR 5785] 5786 5787 5788@app.template_filter() 5789def escapejs(value): 5790 """ 5791 Modified from: 5792 5793 https://stackoverflow.com/questions/12339806/escape-strings-for-javascript-using-jinja2 5794 5795 Escapes string values so that they can appear inside of quotes in a 5796 <script> tag and they won't end the quotes or cause any other 5797 trouble. 5798 """ 5799 retval = [] 5800 for char in value: 5801 if char in _JS_ESCAPES: 5802 retval.append(r'\u{:04X}'.format(ord(char))) 5803 else: 5804 retval.append(char) 5805 5806 return jinja2.Markup(u"".join(retval)) 5807 5808 5809#-----------# 5810# Main code # 5811#-----------# 5812 5813if __name__ == "__main__": 5814 use_ssl = True 5815 if app.config.get("NO_DEBUG_SSL"): 5816 use_ssl = False 5817 else: 5818 try: 5819 import OpenSSL # noqa F811, F401 5820 except ModuleNotFound_or_Import_Error: 5821 use_ssl = False 5822 5823 if use_ssl: 5824 # Run with an ad-hoc SSL context since OpenSSL is available 5825 print("Running with self-signed SSL.") 5826 app.run('localhost', 8787, debug=False, ssl_context='adhoc') 5827 else: 5828 # Run without an SSL context (No OpenSSL) 5829 print("Running without SSL.") 5830 app.run('localhost', 8787, debug=False) 5831 # Note: can't enable debugging because it doesn't set __package__ 5832 # when restarting and so relative imports break in 3.x
144def ensure_directory(target): 145 """ 146 makedirs 2/3 shim. 147 """ 148 if sys.version_info[0] < 3: 149 try: 150 os.makedirs(target) 151 except OSError: 152 pass 153 else: 154 os.makedirs(target, exist_ok=True)
makedirs 2/3 shim.
195class InitApp(flask.Flask): 196 """ 197 A Flask app subclass which runs initialization functions right 198 before app startup. 199 """ 200 def __init__(self, *args, **kwargs): 201 """ 202 Arguments are passed through to flask.Flask.__init__. 203 """ 204 self.actions = [] 205 # Note: we don't use super() here since 2/3 compatibility is 206 # too hard to figure out 207 flask.Flask.__init__(self, *args, **kwargs) 208 209 def init(self, action): 210 """ 211 Registers an init action (a function which will be given the app 212 object as its only argument). These functions will each be 213 called, in order of registration, right before the app starts, 214 withe the app_context active. Returns the action function 215 provided, so that it can be used as a decorator, like so: 216 217 ```py 218 @app.init 219 def setup_some_stuff(app): 220 ... 221 ``` 222 """ 223 self.actions.append(action) 224 return action 225 226 def setup(self): 227 """ 228 Runs all registered initialization actions. If you're not running 229 a debugging setup via the `run` method, you'll need to call this 230 method yourself (e.g., in a .wsgi file). 231 """ 232 with self.app_context(): 233 for action in self.actions: 234 action(self) 235 236 def run(self, *args, **kwargs): 237 """ 238 Overridden run method runs our custom init actions with the app 239 context first. 240 """ 241 self.setup() 242 # Note: we don't use super() here since 2/3 compatibility is 243 # too hard to figure out 244 flask.Flask.run(self, *args, **kwargs)
A Flask app subclass which runs initialization functions right before app startup.
200 def __init__(self, *args, **kwargs): 201 """ 202 Arguments are passed through to flask.Flask.__init__. 203 """ 204 self.actions = [] 205 # Note: we don't use super() here since 2/3 compatibility is 206 # too hard to figure out 207 flask.Flask.__init__(self, *args, **kwargs)
Arguments are passed through to flask.Flask.__init__.
209 def init(self, action): 210 """ 211 Registers an init action (a function which will be given the app 212 object as its only argument). These functions will each be 213 called, in order of registration, right before the app starts, 214 withe the app_context active. Returns the action function 215 provided, so that it can be used as a decorator, like so: 216 217 ```py 218 @app.init 219 def setup_some_stuff(app): 220 ... 221 ``` 222 """ 223 self.actions.append(action) 224 return action
Registers an init action (a function which will be given the app object as its only argument). These functions will each be called, in order of registration, right before the app starts, withe the app_context active. Returns the action function provided, so that it can be used as a decorator, like so:
@app.init
def setup_some_stuff(app):
...
226 def setup(self): 227 """ 228 Runs all registered initialization actions. If you're not running 229 a debugging setup via the `run` method, you'll need to call this 230 method yourself (e.g., in a .wsgi file). 231 """ 232 with self.app_context(): 233 for action in self.actions: 234 action(self)
Runs all registered initialization actions. If you're not running
a debugging setup via the run
method, you'll need to call this
method yourself (e.g., in a .wsgi file).
236 def run(self, *args, **kwargs): 237 """ 238 Overridden run method runs our custom init actions with the app 239 context first. 240 """ 241 self.setup() 242 # Note: we don't use super() here since 2/3 compatibility is 243 # too hard to figure out 244 flask.Flask.run(self, *args, **kwargs)
Overridden run method runs our custom init actions with the app context first.
Inherited Members
- flask.app.Flask
- testing
- secret_key
- permanent_session_lifetime
- send_file_max_age_default
- use_x_sendfile
- json_encoder
- json_decoder
- json_provider_class
- json
- name
- propagate_exceptions
- logger
- jinja_env
- got_first_request
- make_config
- make_aborter
- auto_find_instance_path
- open_instance_resource
- templates_auto_reload
- create_jinja_environment
- create_global_jinja_loader
- select_jinja_autoescape
- update_template_context
- make_shell_context
- env
- debug
- test_client
- test_cli_runner
- register_blueprint
- iter_blueprints
- add_url_rule
- template_filter
- add_template_filter
- template_test
- add_template_test
- template_global
- add_template_global
- before_first_request
- teardown_appcontext
- shell_context_processor
- handle_http_exception
- trap_http_exception
- handle_user_exception
- handle_exception
- log_exception
- raise_routing_exception
- dispatch_request
- full_dispatch_request
- finalize_request
- make_default_options_response
- should_ignore_error
- ensure_sync
- async_to_sync
- url_for
- redirect
- make_response
- create_url_adapter
- inject_url_defaults
- handle_url_build_error
- preprocess_request
- process_response
- do_teardown_request
- do_teardown_appcontext
- app_context
- request_context
- test_request_context
- wsgi_app
- flask.scaffold.Scaffold
- static_folder
- static_url_path
- has_static_folder
- get_send_file_max_age
- send_static_file
- jinja_loader
- open_resource
- get
- post
- put
- delete
- patch
- route
- endpoint
- before_request
- after_request
- teardown_request
- context_processor
- url_value_preprocessor
- url_defaults
- errorhandler
- register_error_handler
347class NotFound(): 348 """ 349 Placeholder class for fallback config values, since None might be a 350 valid value. 351 """ 352 pass
Placeholder class for fallback config values, since None might be a valid value.
355def fallback_config_value(key_or_keys, *look_in): 356 """ 357 Gets a config value from the first dictionary it's defined in, 358 falling back to additional dictionaries if the value isn't found. 359 When the key is specified as a list or tuple of strings, repeated 360 dictionary lookups will be used to retrieve a value. 361 362 Returns the special class `NotFound` as a default value if none of 363 the provided dictionaries have a match for the key or key-path 364 requested. 365 """ 366 for conf_dict in look_in: 367 if isinstance(key_or_keys, (list, tuple)): 368 target = conf_dict 369 for key in key_or_keys: 370 if not isinstance(target, dict): 371 break 372 try: 373 target = target[key] 374 except KeyError: 375 break 376 else: 377 return target 378 elif key_or_keys in conf_dict: 379 return conf_dict[key_or_keys] 380 # else continue the loop 381 return NotFound
Gets a config value from the first dictionary it's defined in, falling back to additional dictionaries if the value isn't found. When the key is specified as a list or tuple of strings, repeated dictionary lookups will be used to retrieve a value.
Returns the special class NotFound
as a default value if none of
the provided dictionaries have a match for the key or key-path
requested.
384def usual_config_value( 385 key_or_keys, 386 taskinfo, 387 task=None, 388 project=None, 389 exercise=None, 390 default=NotFound 391): 392 """ 393 Runs `fallback_config_value` with a task or exercise dictionary, 394 then the specified task info dictionary, then the app.config values, 395 and then the DEFAULT_CONFIG values. 396 397 `task` will take priority over `project`, which takes priority over 398 `exercise`; in general only one should be specified; each must be an 399 ID string (for a task, a project, or an exercise group). 400 401 If provided, the given `default` value will be returned instead of 402 `NotFound` if the result would otherwise be `NotFound`. 403 """ 404 if task is not None: 405 result = fallback_config_value( 406 key_or_keys, 407 taskinfo.get("tasks", {}).get(task, {}), 408 taskinfo, 409 app.config, 410 DEFAULT_CONFIG 411 ) 412 413 elif project is not None: 414 projects = taskinfo.get( 415 "projects", 416 taskinfo.get("psets", {}) 417 ) 418 matching = [p for p in projects if p["id"] == project] 419 if len(matching) > 0: 420 first = matching[0] 421 else: 422 first = {} 423 result = fallback_config_value( 424 key_or_keys, 425 first, 426 taskinfo, 427 app.config, 428 DEFAULT_CONFIG 429 ) 430 431 elif exercise is not None: 432 exgroups = taskinfo.get("exercises", []) 433 matching = [ 434 g 435 for g in exgroups 436 if g.get("group", None) == exercise 437 ] 438 if len(matching) > 0: 439 first = matching[0] 440 else: 441 first = {} 442 result = fallback_config_value( 443 key_or_keys, 444 first, 445 taskinfo, 446 app.config, 447 DEFAULT_CONFIG 448 ) 449 else: 450 result = fallback_config_value( 451 key_or_keys, 452 taskinfo, 453 app.config, 454 DEFAULT_CONFIG 455 ) 456 457 return default if result is NotFound else result
Runs fallback_config_value
with a task or exercise dictionary,
then the specified task info dictionary, then the app.config values,
and then the DEFAULT_CONFIG values.
task
will take priority over project
, which takes priority over
exercise
; in general only one should be specified; each must be an
ID string (for a task, a project, or an exercise group).
If provided, the given default
value will be returned instead of
NotFound
if the result would otherwise be NotFound
.
460@app.init 461def setup_jinja_loader(app): 462 """ 463 Set up templating with a custom loader that loads templates from the 464 potluck package if it can't find them in this package. This is how 465 we share the report template between potluck_server and potluck. 466 """ 467 app.jinja_loader = jinja2.ChoiceLoader([ 468 jinja2.PackageLoader("potluck_server", "templates"), 469 jinja2.PackageLoader("potluck", "templates") 470 ])
Set up templating with a custom loader that loads templates from the potluck package if it can't find them in this package. This is how we share the report template between potluck_server and potluck.
473@app.init 474def enable_CAS(app): 475 """ 476 Enable authentication via a Central Authentication Server. 477 """ 478 global cas 479 cas = flask_cas.CAS(app)
Enable authentication via a Central Authentication Server.
482@app.init 483def setup_potluck_reporting(app): 484 """ 485 Setup for the potluck.render module (uses defaults). 486 """ 487 potluck.render.setup()
Setup for the potluck.render module (uses defaults).
490@app.init 491def create_secret_key(app): 492 """ 493 Set secret key from secret file, or create a new secret key and 494 write it into the secret file. 495 """ 496 if os.path.exists("secret"): 497 with open("secret", 'rb') as fin: 498 app.secret_key = fin.read() 499 else: 500 print("Creating new secret key file 'secret'.") 501 app.secret_key = os.urandom(16) 502 with open("secret", 'wb') as fout: 503 fout.write(app.secret_key)
Set secret key from secret file, or create a new secret key and write it into the secret file.
506@app.init 507def initialize_storage_module(app): 508 """ 509 Initialize file access and storage system. 510 """ 511 storage.init(app.config)
Initialize file access and storage system.
514@app.init 515def ensure_required_folders(app): 516 """ 517 Ensures required folders for the default course/semester. 518 TODO: What about non-default courses/semesters? If they're fine, is 519 this even necessary? 520 """ 521 this_course = app.config.get("DEFAULT_COURSE", 'unknown') 522 this_semester = app.config.get("DEFAULT_SEMESTER", 'unknown') 523 storage.ensure_directory( 524 storage.evaluation_directory(this_course, this_semester) 525 ) 526 storage.ensure_directory( 527 storage.submissions_folder(this_course, this_semester) 528 )
Ensures required folders for the default course/semester. TODO: What about non-default courses/semesters? If they're fine, is this even necessary?
560@app.init 561def enable_talisman(app): 562 """ 563 Enable talisman forced-HTTPS and other security headers if 564 `flask_talisman` is available and it won't interfere with debugging. 565 """ 566 talisman_enabled = False 567 if USE_TALISMAN: 568 try: 569 import flask_talisman 570 # Content-security policy settings 571 csp = { 572 'default-src': "'self'", 573 'script-src': "'self' 'report-sample'", 574 'style-src': "'self'", 575 'img-src': "'self' data:" 576 } 577 flask_talisman.Talisman( 578 app, 579 content_security_policy=csp, 580 content_security_policy_nonce_in=[ 581 'script-src', 582 'style-src' 583 ] 584 ) 585 talisman_enabled = True 586 except ModuleNotFound_or_Import_Error: 587 print( 588 "Warning: module flask_talisman is not available;" 589 " security headers will not be set." 590 ) 591 592 if talisman_enabled: 593 print("Talisman is enabled.") 594 else: 595 print("Talisman is NOT enabled.") 596 # Add csp_nonce global dummy since flask_talisman didn't 597 app.add_template_global( 598 lambda: "-nonce-disabled-", 599 name='csp_nonce' 600 )
Enable talisman forced-HTTPS and other security headers if
flask_talisman
is available and it won't interfere with debugging.
603@app.init 604def setup_seasurf(app): 605 """ 606 Sets up `flask_seasurf` to combat cross-site request forgery, if 607 that module is available. 608 609 Note that `route_deliver` is exempt from CSRF verification. 610 """ 611 global route_deliver 612 try: 613 import flask_seasurf 614 csrf = flask_seasurf.SeaSurf(app) # noqa F841 615 route_deliver = csrf.exempt(route_deliver) 616 except ModuleNotFound_or_Import_Error: 617 print( 618 "Warning: module flask_seasurf is not available; CSRF" 619 " protection will not be enabled." 620 ) 621 # Add csrf_token global dummy since flask_seasurf isn't available 622 app.add_template_global(lambda: "-disabled-", name='csrf_token')
Sets up flask_seasurf
to combat cross-site request forgery, if
that module is available.
Note that route_deliver
is exempt from CSRF verification.
629def augment_arguments(route_function): 630 """ 631 A decorator that modifies a route function to supply `username`, 632 `is_admin`, `masquerade_as`, `effective_user`, and `task_info` 633 keyword arguments along with the other arguments the route receives. 634 Must be applied before app.route, and the first two parameters to the 635 function must be the course and semester. 636 637 Because flask/werkzeug routing requires function signatures to be 638 preserved, we do some dirty work with compile and eval... As a 639 result, this function can only safely be used to decorate functions 640 that don't have any keyword arguments. Furthermore, the 5 augmented 641 arguments must be the last 5 arguments that the function accepts. 642 """ 643 def with_extra_arguments(*args, **kwargs): 644 """ 645 A decorated route function which will be supplied with username, 646 is_admin, masquerade_as, effective_user, and task_info parameters 647 as keyword arguments after other arguments have been supplied. 648 """ 649 # Get username 650 if NOAUTH: 651 username = "test" 652 else: 653 username = cas.username 654 655 # Grab course + semester values 656 course = kwargs.get('course', args[0] if len(args) > 0 else None) 657 semester = kwargs.get( 658 'semester', 659 args[1 if 'course' not in kwargs else 0] 660 if len(args) > 0 else None 661 ) 662 663 if course is None or semester is None: 664 flask.flash( 665 ( 666 "Error: Unable to get course and/or semester. Course" 667 " is {} and semester is {}." 668 ).format(repr(course), repr(semester)) 669 ) 670 return error_response( 671 course, 672 semester, 673 username, 674 ( 675 "Failed to access <code>course</code> and/or" 676 " <code>semester</code> values." 677 ) 678 ) 679 680 # Get admin info 681 admin_info = storage.get_admin_info(course, semester) 682 if admin_info is None: 683 flask.flash("Error loading admin info!") 684 admin_info = {} 685 686 # Check user privileges 687 is_admin, masquerade_as = check_user_privileges(admin_info, username) 688 689 # Effective username 690 effective_user = masquerade_as or username 691 692 # Get basic info on all projects/tasks 693 task_info = storage.get_task_info(course, semester) 694 if task_info is None: # error loading task info 695 flask.flash("Error loading task info!") 696 return error_response( 697 course, 698 semester, 699 username, 700 "Failed to load <code>tasks.json</code>." 701 ) 702 703 # Set pause time for the task info 704 set_pause_time(admin_info, task_info, username, masquerade_as) 705 706 # Update the kwargs 707 kwargs["username"] = username 708 kwargs["is_admin"] = is_admin 709 kwargs["masquerade_as"] = masquerade_as 710 kwargs["effective_user"] = effective_user 711 kwargs["task_info"] = task_info 712 713 # Call the decorated function w/ the extra parameters we've 714 # deduced. 715 return route_function(*args, **kwargs) 716 717 # Grab info on original function signature 718 fname = route_function.__name__ 719 nargs = route_function.__code__.co_argcount 720 argnames = route_function.__code__.co_varnames[:nargs - 5] 721 722 # Create a function with the same signature: 723 code = """\ 724def {name}({args}): 725 return with_extra_arguments({args}) 726""".format(name=fname, args=', '.join(argnames)) 727 env = {"with_extra_arguments": with_extra_arguments} 728 # 2/3 compatibility attempt... 729 if sys.version_info[0] < 3: 730 exec(code) in env, env 731 else: 732 exec(code, env, env) in env, env 733 result = env[fname] 734 735 # Preserve docstring 736 result.__doc__ = route_function.__doc__ 737 738 # Return our synthetic function... 739 return result
A decorator that modifies a route function to supply username
,
is_admin
, masquerade_as
, effective_user
, and task_info
keyword arguments along with the other arguments the route receives.
Must be applied before app.route, and the first two parameters to the
function must be the course and semester.
Because flask/werkzeug routing requires function signatures to be preserved, we do some dirty work with compile and eval... As a result, this function can only safely be used to decorate functions that don't have any keyword arguments. Furthermore, the 5 augmented arguments must be the last 5 arguments that the function accepts.
742def goback(course, semester): 743 """ 744 Returns a flask redirect aimed at either the page that the user came 745 from, or the dashboard if that information isn't available. 746 """ 747 if flask.request.referrer: 748 # If we know where you came from, send you back there 749 return flask.redirect(flask.request.referrer) 750 else: 751 # Otherwise back to the dashboard 752 return flask.redirect( 753 flask.url_for('route_dash', course=course, semester=semester) 754 )
Returns a flask redirect aimed at either the page that the user came from, or the dashboard if that information isn't available.
761@app.route('/') 762def route_index(): 763 """ 764 Checks authentication and redirects to login page or to dashboard. 765 """ 766 if NOAUTH or cas.username: 767 return flask.redirect(flask.url_for('route_default_dash')) 768 else: 769 return flask.redirect(flask.url_for('cas.login'))
Checks authentication and redirects to login page or to dashboard.
772@app.route('/dashboard') 773@flask_cas.login_required 774def route_default_dash(): 775 """ 776 Redirects to dashboard w/ default class/semester. 777 """ 778 return flask.redirect( 779 flask.url_for( 780 'route_dash', 781 course=app.config.get("DEFAULT_COURSE", "unknown"), 782 semester=app.config.get("DEFAULT_SEMESTER", "unknown") 783 ) 784 )
Redirects to dashboard w/ default class/semester.
Displays dashboard w/ links for submitting each project/task & summary information of task grades. Also includes info on submitted exercises.
Displays feedback on a particular task of a particular problem set, for either the 'initial' or 'revision' phase.
Accepts a file submission for a task and initiates an evaluation process for that file. Figures out submission phase automatically based on task info, and assumes that the submission belongs to the authenticated user. However, if the authenticated user is an admin, the "phase" and "target_user" form values can override these assumptions. Redirects to the feedback page for the submitted task, or to the evaluation page if the user is an admin and is not the target user.
Note: there are probably some nasty race conditions if the same user submits the same task simultaneously via multiple requests. We simply hope that that does not happen.
1321def which_exe(target, cwd='.'): 1322 """ 1323 shutil.which 2/3 shim. Prepends cwd to current PATH. 1324 """ 1325 if sys.version_info[0] < 3: 1326 finder = subprocess.Popen( 1327 [ 'which', target ], 1328 cwd=cwd, 1329 stdout=subprocess.PIPE 1330 ) 1331 out, err = finder.communicate() 1332 return out.strip() 1333 else: 1334 return shutil.which( 1335 "potluck_eval", 1336 path=cwd + ':' + os.getenv("PATH") 1337 )
shutil.which 2/3 shim. Prepends cwd to current PATH.
1340def launch_potluck( 1341 course, 1342 semester, 1343 username, 1344 taskid, 1345 target_file, 1346 logfile, 1347 reportfile, 1348 wait=False 1349): 1350 """ 1351 Launches the evaluation process. By default this is fire-and-forget; 1352 we'll look for the output file to determine whether it's finished or 1353 not. However, by passing wait=True you can have the function wait for 1354 the process to terminate before returning. 1355 """ 1356 eval_dir = storage.evaluation_directory(course, semester) 1357 1358 task_info = storage.get_task_info(course, semester) 1359 1360 pev_python = usual_config_value( 1361 "POTLUCK_EVAL_PYTHON", 1362 task_info, 1363 task=taskid, 1364 default=None 1365 ) 1366 if pev_python is None: 1367 python = [] 1368 else: 1369 python = [ pev_python ] 1370 1371 pev_script = usual_config_value( 1372 "POTLUCK_EVAL_SCRIPT", 1373 task_info, 1374 task=taskid, 1375 default=None 1376 ) 1377 if pev_script is None: 1378 potluck_exe = which_exe("potluck_eval", eval_dir) 1379 else: 1380 potluck_exe = os.path.join(os.getcwd(), pev_script) 1381 1382 potluck_args = [ 1383 "--task", taskid, 1384 "--user", username, 1385 "--target", os.path.abspath(target_file), 1386 "--outfile", os.path.abspath(reportfile), 1387 "--clean", 1388 ] 1389 1390 pev_import_from = usual_config_value( 1391 "POTLUCK_EVAL_IMPORT_FROM", 1392 task_info, 1393 task=taskid, 1394 default=None 1395 ) 1396 if pev_import_from is not None: 1397 import_dir = os.path.join(os.getcwd(), pev_import_from) 1398 potluck_args.extend(["--import-from", import_dir]) 1399 with open(logfile, 'wb') as log: 1400 if usual_config_value( 1401 "USE_XVFB", 1402 task_info, 1403 task=taskid, 1404 default=False 1405 ): 1406 # Virtualise frame buffer for programs with graphics, so 1407 # they don't need to create Xwindow windows 1408 # '--auto-servernum', # create a new server?? 1409 # '--auto-display', 1410 # [2019/02/08] Peter: try this instead per comment in -h? 1411 xvfb_err_log = os.path.splitext(logfile)[0] + ".xvfb_errors.log" 1412 full_args = ( 1413 [ 1414 'xvfb-run', 1415 '-d', 1416 # [2019/02/11] Lyn: --auto-display doesn't work but -d 1417 # does (go figure, since they're supposed to be 1418 # synonyms!) 1419 '-e', # --error-file doesn't work 1420 xvfb_err_log, 1421 '--server-args', 1422 usual_config_value( 1423 "XVFB_SERVER_ARGS", 1424 task_info, 1425 task=taskid, 1426 default='-screen 0' 1427 ), # screen properties 1428 '--', 1429 ] + python + [ 1430 potluck_exe, 1431 ] 1432 + potluck_args 1433 ) 1434 else: 1435 # Raw potluck launch without XVFB 1436 full_args = python + [ potluck_exe ] + potluck_args 1437 1438 log.write( 1439 ("Full args: " + repr(full_args) + '\n').encode("utf-8") 1440 ) 1441 log.flush() 1442 1443 p = subprocess.Popen( 1444 full_args, 1445 cwd=eval_dir, 1446 stdout=log, 1447 stderr=log, 1448 ) 1449 1450 if wait: 1451 p.wait()
Launches the evaluation process. By default this is fire-and-forget; we'll look for the output file to determine whether it's finished or not. However, by passing wait=True you can have the function wait for the process to terminate before returning.
Requests (and automatically grants) the default extension on the
given problem set. The extension is applied to the initial phase
only. For now, nonstandard extensions and revision extensions must be
applied by hand-editing JSON files in the extensions/
directory.
Form target for editing extensions for a particular user/project. Only admins can use this route. Can be used to set custom extension values for both initial and revised deadlines.
Note that for now, we're also using it for exercise extensions. TODO: Fix that, since it means that if an exercise has the same ID as a project, their extensions will overwrite each other!!!
Admin-only route that displays a list of forms for each project showing current extension values and allowing the user to edit them and press a button to update them.
Form target for editing custom evaluation info for a particular user/project. Only admins can use this route. Can be used to set custom notes and/or a grade override for a particular user/phase/project/task.
Displays student feedback and also includes a form at the top for adding a custom note and/or overriding the grade.
Visible only once a task's status is final, accounting for all extensions, and if the active user has an evaluated submission for the task, or another task in the same pool (or if they're an admin). Shows the solution code for a particular task, including a formatted version and a link to download the .py file.
2117@app.route( 2118 '/<course>/<semester>/starter/<taskid>.zip', 2119 methods=['GET'] 2120) 2121@flask_cas.login_required 2122def route_starter_zip(course, semester, taskid): 2123 """ 2124 For each task, serves a cached copy of a zip file that includes all 2125 files in that task's starter directory (+ subdirectories, including 2126 ones that are symlinks). If the cached zip does not exist, or if it's 2127 older than any of the files it needs to include, it will be 2128 generated. 2129 2130 `__pycache__` directories and any files they contain will not be 2131 included in the zip file. There is a configurable maximum number of 2132 files, to help detect issues caused by cyclic symbolic links; set 2133 `MAX_STARTER_FILES` to modify this from the default of 100000 (which 2134 is probably already enough that unzipping would be problematic?). 2135 2136 Raises an `OSError` if too many files are present. 2137 """ 2138 # TODO: This feels a bit hardcoded... can we do better? 2139 # Compute filenames and directories 2140 starter_dir = os.path.join( 2141 storage.evaluation_directory(course, semester), 2142 "specs", 2143 taskid, 2144 "starter" 2145 ) 2146 2147 # We 'detect' symlink loops without doing something like stating each 2148 # directory along the way by using a max # of files (the zip would 2149 # become unmanageable at some point anyways 2150 max_starter_files = usual_config_value( 2151 "MAX_STARTER_FILES", 2152 storage.get_task_info(course, semester), 2153 task=taskid, 2154 default=100000 2155 ) 2156 2157 # Note: a symlink-to-an-ancestor will cause an infinite loop here. 2158 starter_files = [] 2159 for dirpath, dirnames, filenames in os.walk( 2160 starter_dir, 2161 followlinks=True 2162 ): 2163 # Don't include __pycache__ directories 2164 if '__pycache__' in dirnames: 2165 dirnames.remove('__pycache__') 2166 2167 for filename in filenames: 2168 starter_files.append( 2169 os.path.relpath(os.path.join(dirpath, filename), starter_dir) 2170 ) 2171 if len(starter_files) > max_starter_files: 2172 raise OSError( 2173 ( 2174 "We've found more than {max_starter_files}" 2175 " starter files; it's likely that you have a" 2176 " symbolic link loop. Aborting..." 2177 ).format(max_starter_files=max_starter_files) 2178 ) 2179 2180 starter_zip = os.path.join( 2181 storage.evaluation_directory(course, semester), 2182 "specs", 2183 taskid, 2184 taskid + ".zip" 2185 ) 2186 2187 # Compute most-recent-modification time for any starter file 2188 updated_at = None 2189 for file in starter_files: 2190 full_path = os.path.join(starter_dir, file) 2191 mtime = os.stat(full_path).st_mtime 2192 if updated_at is None or updated_at < mtime: 2193 updated_at = mtime 2194 2195 # Check for freshness 2196 if ( 2197 not os.path.exists(starter_zip) 2198 or os.stat(starter_zip).st_mtime < updated_at 2199 ): 2200 # Zip up all the starter files, erasing and overwriting the old 2201 # zip file if it was there before 2202 with zipfile.ZipFile(starter_zip, 'w', zipfile.ZIP_DEFLATED) as zout: 2203 for file in starter_files: 2204 full_path = os.path.join(starter_dir, file) 2205 zout.write(full_path, taskid + '/' + file) 2206 2207 with open(starter_zip, 'rb') as fin: 2208 raw_bytes = fin.read() 2209 2210 return flask.Response(raw_bytes, mimetype='application/zip')
For each task, serves a cached copy of a zip file that includes all files in that task's starter directory (+ subdirectories, including ones that are symlinks). If the cached zip does not exist, or if it's older than any of the files it needs to include, it will be generated.
__pycache__
directories and any files they contain will not be
included in the zip file. There is a configurable maximum number of
files, to help detect issues caused by cyclic symbolic links; set
MAX_STARTER_FILES
to modify this from the default of 100000 (which
is probably already enough that unzipping would be problematic?).
Raises an OSError
if too many files are present.
Visible by admins only, this route displays an overview of the status of every student on the roster, for ALL exercises and projects.
Visible by admins only, this route displays an overview of the status of every student on the roster, with links to the feedback views for each student/project/phase/task.
2620@app.route('/<course>/<semester>/deliver', methods=['GET', 'POST']) 2621def route_deliver(course, semester): 2622 """ 2623 This route is accessible by anyone without login, because it is 2624 going to be posted to from Python scripts (see the `potluckDelivery` 2625 module). We will only accept submissions from users on the roster 2626 for the specified course/semester, although because there's no 2627 verification of user IDs, anyone could be sending submissions (TODO: 2628 Fix that using token-based verification!). Also, submissions may 2629 include multiple authors (e.g. when pair programming). 2630 2631 Submitted form data should have the following slots, with all 2632 strings encoded using utf-8: 2633 2634 - 'exercise': The ID of the exercise being submitted. 2635 - 'authors': A JSON-encoded list of usernames that the submission 2636 should be assigned to. 2637 - 'outcomes': A JSON-encoded list of `optimism` outcome-triples, 2638 each containing a boolean indicating success/failure, followed 2639 by a tag string indicating the file + line number of the check 2640 and a second string with the message describing the outcome of 2641 the check. 2642 - 'code': A JSON-encoded list of 2-element lists, each of which has 2643 a filename (or other code-source-identifying-string) and a code 2644 block (as a string). 2645 2646 If some of the data isn't in the formats specified above, a 400 error 2647 will be returned with a string describing what's wrong. 2648 """ 2649 # The potluckDelivery script prints the delivery URL, which may be 2650 # styled as a link. So we redirect anyone accessing this route with 2651 # a GET method to the dashboard. 2652 if flask.request.method == "GET": 2653 return flask.redirect( 2654 flask.url_for('route_dash', course=course, semester=semester) 2655 ) 2656 2657 # Process POST info... 2658 form = flask.request.form 2659 2660 # Get exercise ID 2661 exercise = form.get("exercise", "") 2662 if exercise == "": 2663 return ("Delivery did not specify an exercise.", 400) 2664 2665 # Get authors list, decode the JSON, and ensure it's a list of 2666 # strings. 2667 authorsString = form.get("authors", "") 2668 if authorsString == "": 2669 return ("Delivery did not specify authors.", 400) 2670 2671 try: 2672 authors = json.loads(authorsString) 2673 except Exception: 2674 return ("Specified authors list was not valid JSON.", 400) 2675 2676 if ( 2677 not isinstance(authors, list) 2678 or any(not isinstance(author, anystr) for author in authors) 2679 ): 2680 return ("Specified authors list was not a list of strings.", 400) 2681 2682 # Get outcomes list, decode the JSON, and ensure it's a list of 2683 # 3-element lists each containing a boolean and two strings. 2684 # An empty list of outcomes is not allowed. 2685 outcomesString = form.get("outcomes", "") 2686 if outcomesString == "": 2687 return ("Delivery did not specify any outcomes.", 400) 2688 2689 try: 2690 outcomes = json.loads(outcomesString) 2691 except Exception: 2692 return ("Specified outcomes list was not valid JSON.", 400) 2693 2694 if not isinstance(outcomes, list): 2695 return ("Outcomes object was not a list.", 400) 2696 2697 for i, outcome in enumerate(outcomes): 2698 if ( 2699 not isinstance(outcome, list) 2700 or len(outcome) != 3 2701 or not isinstance(outcome[0], bool) 2702 or not isinstance(outcome[1], anystr) 2703 or not isinstance(outcome[2], anystr) 2704 ): 2705 return ( 2706 "Outcome {} was invalid (each outcome must be a list" 2707 " containing a boolean and two strings)." 2708 ).format(i) 2709 2710 # Get code blocks, decode JSON, and ensure it's a list of pairs of 2711 # strings. It *is* allowed to be an empty list. 2712 codeString = form.get("code", "") 2713 if codeString == "": 2714 return ("Delivery did not specify any code.", 400) 2715 2716 try: 2717 codeBlocks = json.loads(codeString) 2718 except Exception: 2719 return ("Specified code list was not valid JSON.", 400) 2720 2721 if not isinstance(codeBlocks, list): 2722 return ("Code object was not a list.", 400) 2723 2724 for i, block in enumerate(codeBlocks): 2725 if ( 2726 not isinstance(block, list) 2727 or len(block) != 2 2728 or not all(isinstance(part, anystr) for part in block) 2729 ): 2730 return ( 2731 "Code block {} was invalid (each code block must be a" 2732 " list containing two strings)." 2733 ).format(i) 2734 2735 # Check authors against roster 2736 try: 2737 roster = storage.get_roster(course, semester) 2738 except Exception: 2739 return ( 2740 ( 2741 "Could not fetch roster for course {} {}." 2742 ).format(course, semester), 2743 400 2744 ) 2745 2746 if roster is None: 2747 return ( 2748 ( 2749 "There is no roster for course {} {}." 2750 ).format(course, semester), 2751 400 2752 ) 2753 2754 for author in authors: 2755 if author not in roster: 2756 return ( 2757 ( 2758 "Author '{}' is not on the roster for {} {}. You" 2759 " must use your username when specifying an author." 2760 ).format(author, course, semester), 2761 400 2762 ) 2763 2764 # Grab task info and amend it just to determine egroup phases 2765 task_info = storage.get_task_info(course, semester) 2766 2767 # Record the outcomes list for each author (extensions might be 2768 # different so credit might differ per author) 2769 shared_status = None 2770 statuses = {} 2771 for author in authors: 2772 2773 # Get exercise info so we can actually figure out what the 2774 # evaluation would be for these outcomes. 2775 if task_info is None: 2776 return ( 2777 "Failed to load tasks.json for {} {}.".format( 2778 course, 2779 semester 2780 ), 2781 400 2782 ) 2783 2784 # Per-author phase/extension info 2785 this_author_task_info = copy.deepcopy(task_info) 2786 amend_exercises(course, semester, this_author_task_info, {}, author) 2787 2788 einfo = None 2789 for group in this_author_task_info.get("exercises", []): 2790 elist = group["exercises"] 2791 2792 # Make allowances for old format 2793 if isinstance(elist, dict): 2794 for eid in elist: 2795 elist[eid]['id'] = eid 2796 elist = list(elist.values()) 2797 2798 for ex in elist: 2799 if exercise == ex['id']: 2800 einfo = ex 2801 break 2802 if einfo is not None: 2803 break 2804 2805 if einfo is None: 2806 return ( 2807 "Exercise '{}' is not listed in {} {}.".format( 2808 exercise, 2809 course, 2810 semester 2811 ), 2812 400 2813 ) 2814 2815 status, ecredit, gcredit = exercise_credit(einfo, outcomes) 2816 statuses[author] = status 2817 2818 if shared_status is None: 2819 shared_status = status 2820 elif shared_status == "mixed" or shared_status != status: 2821 shared_status = "mixed" 2822 # else it remains the same 2823 2824 storage.save_outcomes( 2825 course, 2826 semester, 2827 author, 2828 exercise, 2829 authors, 2830 outcomes, 2831 codeBlocks, 2832 status, 2833 ecredit, 2834 gcredit 2835 ) 2836 2837 if shared_status == "mixed": 2838 message = ( 2839 "Submission accepted: {}/{} checks passed, but status is" 2840 " different for different authors:\n{}" 2841 ).format( 2842 len([x for x in outcomes if x[0]]), 2843 len(outcomes), 2844 '\n'.join( 2845 " for {}: {}".format(author, status) 2846 for (author, status) in statuses.items() 2847 ) 2848 ) 2849 if any(status != "complete" for status in statuses.values()): 2850 message += ( 2851 "\nNote: this submission is NOT complete for all authors." 2852 ) 2853 else: 2854 message = ( 2855 "Submission accepted: {}/{} checks passed and status is {}." 2856 ).format( 2857 len([x for x in outcomes if x[0]]), 2858 len(outcomes), 2859 shared_status 2860 ) 2861 if shared_status != "complete": 2862 message += "\nNote: this submission is NOT complete." 2863 2864 return message
This route is accessible by anyone without login, because it is
going to be posted to from Python scripts (see the potluckDelivery
module). We will only accept submissions from users on the roster
for the specified course/semester, although because there's no
verification of user IDs, anyone could be sending submissions (TODO:
Fix that using token-based verification!). Also, submissions may
include multiple authors (e.g. when pair programming).
Submitted form data should have the following slots, with all strings encoded using utf-8:
- 'exercise': The ID of the exercise being submitted.
- 'authors': A JSON-encoded list of usernames that the submission should be assigned to.
- 'outcomes': A JSON-encoded list of
optimism
outcome-triples, each containing a boolean indicating success/failure, followed by a tag string indicating the file + line number of the check and a second string with the message describing the outcome of the check. - 'code': A JSON-encoded list of 2-element lists, each of which has a filename (or other code-source-identifying-string) and a code block (as a string).
If some of the data isn't in the formats specified above, a 400 error will be returned with a string describing what's wrong.
Accessible by admins only, this route is the form target for the exercise and/or exercise group override controls.
Visible by admins only, this route displays an overview of the status of every student on the roster, with links to the extensions manager and to feedback views for each student.
3303def error_response(course, semester, username, cause): 3304 """ 3305 Shortcut for displaying major errors to the users so that they can 3306 bug the support line instead of just getting a pure 404. 3307 """ 3308 return flask.render_template( 3309 'error.j2', 3310 course_name=course, 3311 course=course, 3312 semester=semester, 3313 username=username, 3314 announcements="", 3315 support_link=fallback_config_value( 3316 "SUPPORT_LINK", 3317 app.config, 3318 DEFAULT_CONFIG 3319 ), 3320 error=cause, 3321 task_info={} 3322 )
Shortcut for displaying major errors to the users so that they can bug the support line instead of just getting a pure 404.
3325def get_pr_obj(task_info, prid): 3326 """ 3327 Gets the project object with the given ID. Raises a ValueError if 3328 there is no such object, or if there are multiple matches. 3329 """ 3330 psmatches = [ 3331 pr 3332 for pr in task_info.get("projects", task_info["psets"]) 3333 if pr["id"] == prid 3334 ] 3335 if len(psmatches) == 0: 3336 raise ValueError("Unknown problem set '{}'".format(prid)) 3337 elif len(psmatches) > 1: 3338 raise ValueError("Multiple problem sets with ID '{}'!".format(prid)) 3339 else: 3340 return psmatches[0]
Gets the project object with the given ID. Raises a ValueError if there is no such object, or if there are multiple matches.
3343def get_task_obj(task_info, pr_obj, taskid, redirect=Exception): 3344 """ 3345 Extracts a task object with the given ID from the given task info 3346 and project objects, merging project-specific fields with universal 3347 task fields. Raises a ValueError if there is no matching task object 3348 or if there are multiple matches. 3349 """ 3350 universal = task_info["tasks"].get(taskid, None) 3351 taskmatches = [task for task in pr_obj["tasks"] if task["id"] == taskid] 3352 if len(taskmatches) == 0: 3353 raise ValueError( 3354 "Problem set {} has no task '{}'".format(pr_obj["id"], taskid) 3355 ) 3356 elif universal is None: 3357 raise ValueError( 3358 ( 3359 "Problem set {} has a task '{}' but that task has no" 3360 " universal specification." 3361 ).format(pr_obj["id"], taskid) 3362 ) 3363 elif len(taskmatches) > 1: 3364 raise ValueError( 3365 "Multiple tasks in problem set {} with ID '{}'!".format( 3366 pr_obj["id"], 3367 taskid 3368 ) 3369 ) 3370 else: 3371 result = {} 3372 result.update(universal) 3373 result.update(taskmatches[0]) 3374 return result
Extracts a task object with the given ID from the given task info and project objects, merging project-specific fields with universal task fields. Raises a ValueError if there is no matching task object or if there are multiple matches.
3377def check_user_privileges(admin_info, username): 3378 """ 3379 Returns a pair containing a boolean indicating whether a user is an 3380 admin or not, and either None, or a string indicating the username 3381 that the given user is masquerading as. 3382 3383 Requires admin info as returned by get_admin_info. 3384 """ 3385 admins = admin_info.get("admins", []) 3386 is_admin = username in admins 3387 3388 masquerade_as = None 3389 # Only admins can possibly masquerade 3390 if is_admin: 3391 masquerade_as = admin_info.get("MASQUERADE", {}).get(username) 3392 # You cannot masquerade as an admin 3393 if masquerade_as in admins: 3394 flask.flash("Error: You cannot masquerade as another admin!") 3395 masquerade_as = None 3396 elif admin_info.get("MASQUERADE", {}).get(username): 3397 print( 3398 ( 3399 "Warning: User '{}' cannot masquerade because they are" 3400 + "not an admin." 3401 ).format(username) 3402 ) 3403 3404 return (is_admin, masquerade_as)
Returns a pair containing a boolean indicating whether a user is an admin or not, and either None, or a string indicating the username that the given user is masquerading as.
Requires admin info as returned by get_admin_info.
3407def set_pause_time(admin_info, task_info, username, masquerade_as=None): 3408 """ 3409 Sets the PAUSE_AT value in the given task_info object based on any 3410 PAUSE_USERS entries for either the given true username or the given 3411 masquerade username. The pause value for the username overrides the 3412 value for the masqueraded user, so that by setting PAUSE_AT for an 3413 admin account plus creating a masquerade entry, you can act as any 3414 user at any point in time. 3415 """ 3416 pu = admin_info.get("PAUSE_USERS", {}) 3417 if username in pu: 3418 task_info["PAUSE_AT"] = pu[username] 3419 elif masquerade_as in pu: 3420 task_info["PAUSE_AT"] = pu[masquerade_as] 3421 elif "PAUSE_AT" in admin_info: 3422 task_info["PAUSE_AT"] = admin_info["PAUSE_AT"] 3423 # else we don't set PAUSE_AT at all
Sets the PAUSE_AT value in the given task_info object based on any PAUSE_USERS entries for either the given true username or the given masquerade username. The pause value for the username overrides the value for the masqueraded user, so that by setting PAUSE_AT for an admin account plus creating a masquerade entry, you can act as any user at any point in time.
3426def amend_task_info(course, semester, task_info, username): 3427 """ 3428 Amends task info object with extra keys in each project to indicate 3429 project state. Also adds summary information to each task of the 3430 project based on user feedback generated so far. Also checks potluck 3431 inflight status and adds a "submitted" key to each task where 3432 appropriate. Template code should be careful not to reveal feedback 3433 info not warranted by the current project state. 3434 """ 3435 for project in task_info.get("projects", task_info["psets"]): 3436 # Add status info to the project object: 3437 amend_project_and_tasks( 3438 course, 3439 semester, 3440 task_info, 3441 project, 3442 username 3443 )
Amends task info object with extra keys in each project to indicate project state. Also adds summary information to each task of the project based on user feedback generated so far. Also checks potluck inflight status and adds a "submitted" key to each task where appropriate. Template code should be careful not to reveal feedback info not warranted by the current project state.
3446def amend_project_and_tasks( 3447 course, 3448 semester, 3449 task_info, 3450 project_obj, 3451 username 3452): 3453 """ 3454 Calls amend_project on the given project object, and then amend_task 3455 on each task in it, adding "revision" entries to each task w/ 3456 amended revision info. 3457 3458 Also adds "pool_status" keys to each task indicating the best status 3459 of any task that's pooled with that one. That will be 'complete' if 3460 there's a complete task in the pool, 'some_submission' if there's 3461 any unsubmitted task in the pool, an 'unsubmitted' if there are no 3462 submitted tasks in the pool. 3463 """ 3464 amend_project(course, semester, task_info, project_obj, username) 3465 # Add summary info to each task, and duplicate for revisions and 3466 # belated versions 3467 for task in project_obj["tasks"]: 3468 rev_task = copy.deepcopy(task) 3469 belated_task = copy.deepcopy(task) 3470 amend_task( 3471 course, 3472 semester, 3473 task_info, 3474 project_obj["id"], 3475 rev_task, 3476 username, 3477 "revision" 3478 ) 3479 task["revision"] = rev_task 3480 3481 amend_task( 3482 course, 3483 semester, 3484 task_info, 3485 project_obj["id"], 3486 belated_task, 3487 username, 3488 "belated" 3489 ) 3490 task["belated"] = belated_task 3491 3492 amend_task( 3493 course, 3494 semester, 3495 task_info, 3496 project_obj["id"], 3497 task, 3498 username, 3499 "initial" 3500 ) 3501 3502 # Figure out best status in each pool 3503 pool_statuses = {} 3504 for task in project_obj["tasks"]: 3505 # Get submission status 3506 status = task["submission_status"] 3507 if status not in ("unsubmitted", "complete"): 3508 status = "some_submission" 3509 3510 rev_status = task.get("revision", {}).get( 3511 "submission_status", 3512 "unsubmitted" 3513 ) 3514 if rev_status not in ("unsubmitted", "complete"): 3515 rev_status = "some_submission" 3516 bel_status = task.get("belated", {}).get( 3517 "submission_status", 3518 "unsubmitted" 3519 ) 3520 if bel_status not in ("unsubmitted", "complete"): 3521 bel_status = "some_submission" 3522 3523 # Figure out best status across revision/belated phases 3524 points = map( 3525 lambda x: { 3526 "unsubmitted": 0, 3527 "some_submission": 1, 3528 "complete": 2 3529 }[x], 3530 [status, rev_status, bel_status] 3531 ) 3532 status = { 3533 0: "unsubmitted", 3534 1: "some_submission", 3535 2: "complete" 3536 }[max(points)] 3537 3538 # Figure out this task's pool and update the score for that pool 3539 pool = task_pool(task) 3540 3541 prev_status = pool_statuses.get(pool) 3542 if ( 3543 prev_status is None 3544 or prev_status == "unsubmitted" 3545 or prev_status == "some_submission" and status == "complete" 3546 ): 3547 pool_statuses[pool] = status 3548 3549 # Now assign pool_status slots to each task 3550 for task in project_obj["tasks"]: 3551 pool = task_pool(task) 3552 task["pool_status"] = pool_statuses[pool]
Calls amend_project on the given project object, and then amend_task on each task in it, adding "revision" entries to each task w/ amended revision info.
Also adds "pool_status" keys to each task indicating the best status of any task that's pooled with that one. That will be 'complete' if there's a complete task in the pool, 'some_submission' if there's any unsubmitted task in the pool, an 'unsubmitted' if there are no submitted tasks in the pool.
3555def amend_project(course, semester, task_info, project_obj, username): 3556 """ 3557 Adds a "status" key to the given problem set object (which should be 3558 part of the given task info). The username is used to look up 3559 extension information. 3560 """ 3561 initial_ext = storage.get_extension( 3562 course, 3563 semester, 3564 username, 3565 project_obj["id"], 3566 "initial" 3567 ) 3568 if initial_ext is None: 3569 flask.flash( 3570 "Error fetching initial extension info (treating as 0)." 3571 ) 3572 initial_ext = 0 3573 3574 revision_ext = storage.get_extension( 3575 course, 3576 semester, 3577 username, 3578 project_obj["id"], 3579 "revision" 3580 ) 3581 if revision_ext is None: 3582 flask.flash( 3583 "Error fetching revision extension info (treating as 0)." 3584 ) 3585 revision_ext = 0 3586 3587 project_obj["status"] = project_status_now( 3588 username, 3589 task_info, 3590 project_obj, 3591 extensions=[initial_ext, revision_ext] 3592 )
Adds a "status" key to the given problem set object (which should be part of the given task info). The username is used to look up extension information.
3595def amend_task(course, semester, task_info, prid, task, username, phase): 3596 """ 3597 Adds task-state-related keys to the given task object. The following 3598 keys are added: 3599 3600 - "feedback_summary": A feedback summary object (see 3601 `get_feedback_summary`). 3602 - "time_spent": The time spent info that the user entered for time 3603 spent when submitting the task (see 3604 `potluck_app.storage.fetch_time_spent` for the format). Will be 3605 None if that info isn't available. 3606 - "submitted": True if the user has attempted submission (even if 3607 there were problems) or if we have feedback for them (regardless 3608 of anything else). 3609 - "submitted_at": A `datetime.datetime` object representing when 3610 the submission was received, or None if there is no recorded 3611 submission. 3612 - "eval_elapsed": A `datetime.timedelta` that represents the time 3613 elapsed since evaluation was started for the submission (in 3614 possibly fractional seconds). 3615 - "eval_timeout": A `datetime.timedelta` representing the number 3616 of seconds before the evaluation process should time out. 3617 - "eval_status": The status of the evaluation process (see 3618 `get_inflight`). 3619 - "submission_status": A string representing the status of the 3620 submission overall. One of: 3621 - "unsubmitted": no submission yet. 3622 - "inflight": has been submitted; still being evaluated. 3623 - "unprocessed": evaluated but there's an issue (evaluation 3624 crashed or elsehow failed to generate feedback). 3625 - "issue": evaluated but there's an issue (evaluation warning or 3626 'incomplete' evaluation result.) 3627 - "complete": Evaluated and there's no major issue (grade may or 3628 may not be perfect, but it better than "incomplete"). 3629 - "submission_icon": A one-character string used to represent the 3630 submission status. 3631 - "submission_desc": A brief human-readable version of the 3632 submission status. 3633 - "grade": A numerical grade value, derived from the evaluation via 3634 the EVALUATION_SCORES dictionary defined in the config file. If 3635 no grade has been assigned, it will be the string '?' instead of 3636 a number. Grade overrides are factored in if present. 3637 - "grade_overridden": A boolean indicating whether the grade was 3638 overridden or not. Will be true when there's an override active 3639 even if the override indicates the same score as the automatic 3640 grade. 3641 - "timeliness": A numerical score value for timeliness points, only 3642 present when an override has been issued. Normally, timeliness 3643 points are computed for an already-augmented task based on the 3644 initial/revised/belated submission statuses. 3645 - "timeliness_overridden": A boolean indicating whether the 3646 timeliness score was overridden or not. 3647 - "notes": A string indicating the markdown source for custom notes 3648 applied to the task by an evaluator. 3649 - "notes_html": A string of HTML code rendered from the notes 3650 markdown. 3651 - "max_score": A number representing the maximum score possible 3652 based on the phase of the submission, either SCORE_BASIS for 3653 initial submissions, REVISION_MAX_SCORE for revisions, or 3654 BELATED_MAX_SCORE for belated submissions. 3655 - "max_revision_score": A number representing the max score possible 3656 on a revision of this task (regardless of whether this is an 3657 initial or revised submission) 3658 - "max_belated_score": A number representing the max score possible 3659 on a belated submission of this task (regardless of whether this 3660 is an initial, revised, or belated submission) 3661 """ 3662 # Fetch basic info 3663 task["feedback_summary"] = storage.get_feedback_summary( 3664 course, 3665 semester, 3666 task_info, 3667 username, 3668 phase, 3669 prid, 3670 task["id"] 3671 ) 3672 task["time_spent"] = storage.fetch_time_spent( 3673 course, 3674 semester, 3675 username, 3676 phase, 3677 prid, 3678 task["id"] 3679 ) 3680 3681 # Get submitted value 3682 # Note that we hedge here against the possibility that the feedback 3683 # summary isn't readable since some kinds of bad crashes of the 3684 # evaluator can cause that to happen (e.g., student accidentally 3685 # monkey-patches json.dump so that no report can be written). 3686 task["submitted"] = (task.get("feedback_summary") or {}).get("submitted") 3687 3688 # Get inflight info so we know about timeouts 3689 ts, logfile, reportfile, status = storage.get_inflight( 3690 course, 3691 semester, 3692 username, 3693 phase, 3694 prid, 3695 task["id"] 3696 ) 3697 3698 # Get current time (IN UTC) 3699 now = potluck.time_utils.now() 3700 3701 # Time submitted and time elapsed since submission 3702 if ts == "error": 3703 task["submitted_at"] = "unknown" 3704 task["eval_elapsed"] = "unknown" 3705 elif ts is not None: 3706 submit_time = potluck.time_utils.local_time( 3707 task_info, 3708 potluck.time_utils.time_from_timestring(ts) 3709 ) 3710 task["submitted_at"] = submit_time 3711 task["eval_elapsed"] = now - submit_time 3712 else: 3713 try: 3714 submit_time = potluck.time_utils.local_time( 3715 task_info, 3716 potluck.time_utils.time_from_timestring( 3717 task["feedback_summary"]["timestamp"] 3718 ) 3719 ) 3720 task["submitted_at"] = submit_time 3721 task["eval_elapsed"] = now - submit_time 3722 except Exception: 3723 task["submitted_at"] = None 3724 task["eval_elapsed"] = None 3725 3726 task["eval_timeout"] = datetime.timedelta( 3727 seconds=usual_config_value( 3728 "FINAL_EVAL_TIMEOUT", 3729 task_info, 3730 task=task["id"] 3731 ) 3732 ) 3733 3734 # Set eval_status 3735 if ts == "error": 3736 task["eval_status"] = "unknown" 3737 else: 3738 task["eval_status"] = status 3739 3740 # Override submitted value 3741 if status is not None: 3742 task["submitted"] = True 3743 3744 # Add max score info 3745 max_score = usual_config_value( 3746 "SCORE_BASIS", 3747 task_info, 3748 task=task["id"], 3749 default=100 3750 ) 3751 revision_max = usual_config_value( 3752 "REVISION_MAX_SCORE", 3753 task_info, 3754 task=task["id"], 3755 default=100 3756 ) 3757 belated_max = usual_config_value( 3758 "BELATED_MAX_SCORE", 3759 task_info, 3760 task=task["id"], 3761 default=85 3762 ) 3763 if phase == "revision": 3764 task["max_score"] = revision_max 3765 elif phase == "belated": 3766 task["max_score"] = belated_max 3767 else: 3768 task["max_score"] = max_score 3769 task["max_revision_score"] = revision_max 3770 task["max_belated_score"] = belated_max 3771 3772 # Add grade info 3773 if task["eval_status"] in ("unknown", "initial", "in_progress"): 3774 task["grade"] = "?" 3775 elif task["eval_status"] in ("error", "expired"): 3776 task["grade"] = 0 3777 elif task["eval_status"] == "completed" or task["submitted"]: 3778 task["grade"] = usual_config_value( 3779 [ 3780 "EVALUATION_SCORES", 3781 task["feedback_summary"]["evaluation"] 3782 ], 3783 task_info, 3784 task=task["id"], 3785 default=usual_config_value( 3786 ["EVALUATION_SCORES", "__other__"], 3787 task_info, 3788 task=task["id"], 3789 default="???" 3790 ) 3791 ) 3792 if task["grade"] == "???": 3793 flask.flash( 3794 ( 3795 "Warning: evaluation '{}' has not been assigned a" 3796 + " grade value!" 3797 ).format( 3798 task["feedback_summary"]["evaluation"] 3799 ) 3800 ) 3801 task["grade"] = None 3802 else: 3803 task["grade"] = None 3804 3805 # Check for a grade override and grading note 3806 notes, notes_html, override, timeliness_override = get_evaluation_info( 3807 course, 3808 semester, 3809 username, 3810 phase, 3811 prid, 3812 task["id"] 3813 ) 3814 task["notes"] = notes 3815 task["notes_html"] = notes_html 3816 if override == '': 3817 task["grade_overridden"] = False 3818 else: 3819 task["grade_overridden"] = True 3820 task["grade"] = override 3821 3822 if timeliness_override == '': 3823 task["timeliness_overridden"] = False 3824 # No timeliness slot at all 3825 else: 3826 if phase != 'initial': 3827 flask.flash( 3828 ( 3829 "Warning: timeliness override {} for {} {} in phase" 3830 " {} will be ignored: only overrides for the" 3831 " initial phase are applied." 3832 ).format(timeliness_override, prid, task["id"], phase) 3833 ) 3834 task["timeliness_overridden"] = True 3835 task["timeliness"] = timeliness_override 3836 3837 # Set detailed submission status along with icon and description 3838 if task["eval_status"] == "unknown": 3839 task["submission_status"] = "inflight" 3840 task["submission_icon"] = "‽" 3841 task["submission_desc"] = "status unknown" 3842 if task["eval_status"] in ("initial", "in_progress"): 3843 task["submission_status"] = "inflight" 3844 task["submission_icon"] = "?" 3845 task["submission_desc"] = "evaluation in progress" 3846 elif task["eval_status"] in ("error", "expired"): 3847 task["submission_status"] = "unprocessed" 3848 task["submission_icon"] = "☹" 3849 task["submission_desc"] = "processing error" 3850 elif task["eval_status"] == "completed" or task["submitted"]: 3851 report = task["feedback_summary"] 3852 if report["warnings"]: 3853 task["submission_status"] = "issue" 3854 task["submission_icon"] = "✗" 3855 task["submission_desc"] = "major issue" 3856 elif report["evaluation"] == "incomplete": 3857 task["submission_status"] = "issue" 3858 task["submission_icon"] = "✗" 3859 task["submission_desc"] = "incomplete submission" 3860 elif report["evaluation"] == "not evaluated": 3861 task["submission_status"] = "unprocessed" 3862 task["submission_icon"] = "☹" 3863 task["submission_desc"] = "submission not evaluated" 3864 else: 3865 task["submission_status"] = "complete" 3866 task["submission_icon"] = "✓" 3867 task["submission_desc"] = "submitted" 3868 else: 3869 task["submission_status"] = "unsubmitted" 3870 task["submission_icon"] = "…" 3871 task["submission_desc"] = "not yet submitted"
Adds task-state-related keys to the given task object. The following keys are added:
- "feedback_summary": A feedback summary object (see
get_feedback_summary
). - "time_spent": The time spent info that the user entered for time
spent when submitting the task (see
potluck_app.storage.fetch_time_spent
for the format). Will be None if that info isn't available. - "submitted": True if the user has attempted submission (even if there were problems) or if we have feedback for them (regardless of anything else).
- "submitted_at": A
datetime.datetime
object representing when the submission was received, or None if there is no recorded submission. - "eval_elapsed": A
datetime.timedelta
that represents the time elapsed since evaluation was started for the submission (in possibly fractional seconds). - "eval_timeout": A
datetime.timedelta
representing the number of seconds before the evaluation process should time out. - "eval_status": The status of the evaluation process (see
get_inflight
). - "submission_status": A string representing the status of the
submission overall. One of:
- "unsubmitted": no submission yet.
- "inflight": has been submitted; still being evaluated.
- "unprocessed": evaluated but there's an issue (evaluation crashed or elsehow failed to generate feedback).
- "issue": evaluated but there's an issue (evaluation warning or 'incomplete' evaluation result.)
- "complete": Evaluated and there's no major issue (grade may or may not be perfect, but it better than "incomplete").
- "submission_icon": A one-character string used to represent the submission status.
- "submission_desc": A brief human-readable version of the submission status.
- "grade": A numerical grade value, derived from the evaluation via the EVALUATION_SCORES dictionary defined in the config file. If no grade has been assigned, it will be the string '?' instead of a number. Grade overrides are factored in if present.
- "grade_overridden": A boolean indicating whether the grade was overridden or not. Will be true when there's an override active even if the override indicates the same score as the automatic grade.
- "timeliness": A numerical score value for timeliness points, only present when an override has been issued. Normally, timeliness points are computed for an already-augmented task based on the initial/revised/belated submission statuses.
- "timeliness_overridden": A boolean indicating whether the timeliness score was overridden or not.
- "notes": A string indicating the markdown source for custom notes applied to the task by an evaluator.
- "notes_html": A string of HTML code rendered from the notes markdown.
- "max_score": A number representing the maximum score possible based on the phase of the submission, either SCORE_BASIS for initial submissions, REVISION_MAX_SCORE for revisions, or BELATED_MAX_SCORE for belated submissions.
- "max_revision_score": A number representing the max score possible on a revision of this task (regardless of whether this is an initial or revised submission)
- "max_belated_score": A number representing the max score possible on a belated submission of this task (regardless of whether this is an initial, revised, or belated submission)
3874def exercise_credit(exercise_info, outcomes): 3875 """ 3876 Returns a triple containing a submission status string, a number for 3877 exercise credit (or None), and a number for group credit (possibly 3878 0). These are based on whether the given outcomes list matches the 3879 expected outcomes for the given exercise info (should be an 3880 individual exercise dictionary). These values will NOT account for 3881 whether the submission is on-time or late. 3882 3883 The status string will be one of: 3884 3885 - "complete" if all outcomes are successful. 3886 - "partial" if some outcome failed, but at least one non-passive 3887 outcome succeeded (passive outcomes are those expected to succeed 3888 even when no code is written beyond the starter code, but which 3889 may fail if bad code is written). 3890 - "incomplete" if not enough outcomes are successful for partial 3891 completeness, or if there's an issue like the wrong number of 3892 outcomes being reported. 3893 3894 This function won't return it, but "unsubmitted" is another possible 3895 status string used elsewhere. 3896 """ 3897 group_credit = 0 3898 exercise_credit = None 3899 n_outcomes = len(outcomes) 3900 3901 exp_outcomes = exercise_info.get('count') 3902 if exp_outcomes is None and 'per_outcome' in exercise_info: 3903 exp_outcomes = len(exercise_info['per_outcome']) 3904 3905 if exp_outcomes is None: 3906 # Without info on how many outcomes we're expecting, we currently 3907 # don't have a way to know if an outcome is passive (or which 3908 # concept(s) it might deal with). 3909 # TODO: Less fragile way to associate outcomes w/ concepts! 3910 3911 # In this case, at least one outcome is required! 3912 if len(outcomes) == 0: 3913 submission_status = "incomplete" 3914 else: 3915 # Otherwise, all-success -> complete; one+ success -> partial 3916 passed = [outcome for outcome in outcomes if outcome[0]] 3917 if len(passed) == len(outcomes): 3918 submission_status = "complete" 3919 exercise_credit = 1 3920 elif len(passed) > 0: 3921 submission_status = "partial" 3922 exercise_credit = 0.5 3923 else: 3924 submission_status = "incomplete" 3925 exercise_credit = 0 3926 # Accumulate credit 3927 group_credit = exercise_credit 3928 elif n_outcomes != exp_outcomes: 3929 # If we have an expectation for the number of outcomes and the 3930 # actual number doesn't match that, we won't know which outcomes 3931 # might be passive vs. active, and we don't know how to map 3932 # outcomes onto per-outcome concepts... so we just ignore the 3933 # whole exercise and count it as not-yet-submitted. 3934 # TODO: Less fragile way to associate outcomes w/ concepts! 3935 submission_status = "incomplete" 3936 # No group credit accrues in this case 3937 elif len(outcomes) == 0: 3938 # this exercise doesn't contribute to any concept 3939 # statuses, and that's intentional. Since it doesn't 3940 # have outcomes, any submission counts as complete. 3941 submission_status = "complete" 3942 # Here we directly add to group credit but leave 3943 # exercise_credit as None. 3944 group_credit = 1 3945 else: 3946 # Get list of outcome indices for successes 3947 passed = [ 3948 i 3949 for i in range(len(outcomes)) 3950 if outcomes[i][0] 3951 ] 3952 # Get list of which outcomes are passive 3953 passives = exercise_info.get("passive", []) 3954 if len(passed) == n_outcomes: 3955 # If everything passed, the overall is a pass 3956 exercise_credit = 1 3957 submission_status = "complete" 3958 elif any(i not in passives for i in passed): 3959 # at least one non-passive passed -> partial 3960 exercise_credit = 0.5 3961 submission_status = "partial" 3962 else: 3963 # only passing tests were partial & at least one 3964 # failed -> no credit 3965 exercise_credit = 0 3966 submission_status = "incomplete" 3967 3968 # Set group credit 3969 group_credit = exercise_credit 3970 3971 return (submission_status, exercise_credit, group_credit)
Returns a triple containing a submission status string, a number for exercise credit (or None), and a number for group credit (possibly 0). These are based on whether the given outcomes list matches the expected outcomes for the given exercise info (should be an individual exercise dictionary). These values will NOT account for whether the submission is on-time or late.
The status string will be one of:
- "complete" if all outcomes are successful.
- "partial" if some outcome failed, but at least one non-passive outcome succeeded (passive outcomes are those expected to succeed even when no code is written beyond the starter code, but which may fail if bad code is written).
- "incomplete" if not enough outcomes are successful for partial completeness, or if there's an issue like the wrong number of outcomes being reported.
This function won't return it, but "unsubmitted" is another possible status string used elsewhere.
3974def get_exercise_deadline( 3975 course, 3976 semester, 3977 username, 3978 task_info, 3979 egroup 3980): 3981 """ 3982 Given a particular course/semester/user, the task_info object, and 3983 an exercise group dictionary form within the task info, fetches that 3984 user's extension info for that group and returns a 3985 `datetime.datetime` object representing that user's deadline for 3986 that exercise group. 3987 """ 3988 # Standard extension hours 3989 standard_ext_hrs = task_info.get("extension_hours", 24) 3990 3991 # Get extension value 3992 extension = storage.get_extension( 3993 course, 3994 semester, 3995 username, 3996 egroup["group"], 3997 "initial" 3998 ) 3999 if extension is None: 4000 flask.flash( 4001 "Error fetching exercise extension info (treating as 0)." 4002 ) 4003 extension = 0 4004 elif extension is True: 4005 extension = standard_ext_hrs 4006 elif extension is False: 4007 extension = 0 4008 elif not isinstance(extension, (int, float)): 4009 flask.flash( 4010 "Ignoring invalid initial extension value '{}'".format( 4011 extension 4012 ) 4013 ) 4014 extension = 0 4015 4016 # Get timely time 4017 timely_by = potluck.time_utils.task_time__time( 4018 task_info, 4019 egroup["timely"], 4020 default_time_of_day=task_info.get( 4021 "default_release_time_of_day", 4022 "23:59" 4023 ) 4024 ) 4025 4026 # Apply the extension 4027 return timely_by + datetime.timedelta(hours=extension)
Given a particular course/semester/user, the task_info object, and
an exercise group dictionary form within the task info, fetches that
user's extension info for that group and returns a
datetime.datetime
object representing that user's deadline for
that exercise group.
4030def fetch_all_best_outcomes( 4031 course, 4032 semester, 4033 username, 4034 task_info, 4035 only_groups=None, 4036 only_exercises=None 4037): 4038 """ 4039 Fetches a dictionary mapping each individual exercise ID to the best 4040 outcome for that exercise (omitting keys for exercise IDs where the 4041 user hasn't submitted anything yet). 4042 4043 If `only_groups` is supplied, it should be a sequence of groups to 4044 fetch outcomes for (instead of 'all groups') and if `only_exercises` 4045 is supplied, it should be a sequence of specific exercises to fetch 4046 outcomes for. When both are given, exercises not in one of the 4047 specified groups will not be fetched. 4048 """ 4049 outcomes = {} 4050 use_groups = task_info["exercises"] 4051 if only_groups is not None: 4052 use_groups = [ 4053 group 4054 for group in use_groups 4055 if group["group"] in only_groups 4056 ] 4057 for egroup in use_groups: 4058 deadline = get_exercise_deadline( 4059 course, 4060 semester, 4061 username, 4062 task_info, 4063 egroup 4064 ) 4065 for ex in egroup["exercises"]: 4066 eid = ex["id"] 4067 # Filter by eid if we were told to only do some 4068 if only_exercises is not None and eid not in only_exercises: 4069 continue 4070 ebest = storage.fetch_best_outcomes( 4071 course, 4072 semester, 4073 username, 4074 eid, 4075 deadline, 4076 usual_config_value( 4077 "LATE_EXERCISE_CREDIT_FRACTION", 4078 task_info, 4079 exercise=eid, 4080 default=0.5 4081 ) 4082 ) 4083 if ebest is not None: 4084 outcomes[eid] = ebest 4085 4086 return outcomes
Fetches a dictionary mapping each individual exercise ID to the best outcome for that exercise (omitting keys for exercise IDs where the user hasn't submitted anything yet).
If only_groups
is supplied, it should be a sequence of groups to
fetch outcomes for (instead of 'all groups') and if only_exercises
is supplied, it should be a sequence of specific exercises to fetch
outcomes for. When both are given, exercises not in one of the
specified groups will not be fetched.
4089def amend_exercises(course, semester, task_info, outcomes, username): 4090 """ 4091 Amends the exercises in the provided task info based on the given 4092 outcomes. It adds time + submission statuses for each exercise, as 4093 well as extension info based on the provided 4094 course/semester/username. 4095 4096 Each exercise group will gain a "phase" slot with a string 4097 indicating which time phase it's in (see the "exercises" entry 4098 above). Each group will also gain an "extension" slot that holds a 4099 number specifying how many hours of extension the user has been 4100 granted for that exercise group, and a "timely_by" slot that holds a 4101 datetime object indicating the deadline with any extension factored 4102 in. 4103 4104 Also, each individual exercise which has been submitted will 4105 gain the following slots: 4106 4107 - a "status" slot with a submission status string (also see above). 4108 - a "credit" slot with a numerical credit value, or None for 4109 unsubmitted exercises. 4110 - a "group_credit" slot with a numerical credit value towards group 4111 credit, which is usually the same as "credit". 4112 4113 Finally, the exercise groups will gain a "credit_fraction" slot 4114 indicating what fraction of credit has been received, and a "status" 4115 slot aggregating the statuses of its components, depending on its 4116 phase. This group status is determined as follows: 4117 4118 1. If all of the exercises in the group are complete, it counts as 4119 "perfect". 4120 2. If at least 80% (configurable as `EXERCISE_GROUP_THRESHOLD`) of 4121 the exercises in the group are complete (counting 4122 partially-complete exercises as 1/2) then the group counts as 4123 "complete". 4124 3. If at least `EXERCISE_GROUP_PARTIAL_THRESHOLD` (default 2/5) but 4125 less than the `EXERCISE_GROUP_THRESHOLD` fraction of the 4126 exercises are complete, and the phase is "due" then the status 4127 will be "partial". 4128 4. If a smaller fraction than `EXERCISE_GROUP_PARTIAL_THRESHOLD` of 4129 the exercises are complete (still counting partial completions 4130 as 1/2) and the phase is "due" then the status will be 4131 "incomplete". 4132 5. If the status would be less than "complete" but the phase is 4133 "released" instead of "due" then the status will be "pending" 4134 (including when zero exercises have been submitted yet). 4135 6. If the phase is "prerelease" then the status will be "unreleased" 4136 unless submissions to one or more exercises have already 4137 happened, in which case its status will be one of "pending", 4138 "complete", or "perfect" depending on how many exercises are 4139 complete. 4140 4141 Note: an empty outcomes dictionary may be provided if you only care 4142 about setting group phase and extension info and are willing to let 4143 credit info be inaccurate. 4144 """ 4145 # Get current time: 4146 if "PAUSE_AT" in task_info and task_info["PAUSE_AT"]: 4147 now = potluck.time_utils.task_time__time( 4148 task_info, 4149 task_info["PAUSE_AT"] 4150 ) 4151 else: 4152 now = potluck.time_utils.now() 4153 4154 # Standard extension hours 4155 standard_ext_hrs = task_info.get("extension_hours", 24) 4156 4157 # Amend groups and exercises 4158 for group in task_info.get("exercises", []): 4159 # Get extension value 4160 extension = storage.get_extension( 4161 course, 4162 semester, 4163 username, 4164 group["group"], 4165 "initial" 4166 ) 4167 if extension is None: 4168 flask.flash( 4169 "Error fetching exercise extension info (treating as 0)." 4170 ) 4171 extension = 0 4172 elif extension is True: 4173 extension = standard_ext_hrs 4174 elif extension is False: 4175 extension = 0 4176 elif not isinstance(extension, (int, float)): 4177 flask.flash( 4178 "Ignoring invalid initial extension value '{}'".format( 4179 extension 4180 ) 4181 ) 4182 extension = 0 4183 # Now 'extension' is a number so we store it 4184 group["extension"] = extension 4185 4186 # Get release + timely times 4187 release_at = potluck.time_utils.task_time__time( 4188 task_info, 4189 group["release"], 4190 default_time_of_day=task_info.get( 4191 "default_release_time_of_day", 4192 "23:59" 4193 ) 4194 ) 4195 timely_by = potluck.time_utils.task_time__time( 4196 task_info, 4197 group["timely"], 4198 default_time_of_day=task_info.get( 4199 "default_release_time_of_day", 4200 "23:59" 4201 ) 4202 ) 4203 4204 # Apply the extension 4205 timely_by += datetime.timedelta(hours=extension) 4206 4207 # Store calculated deadline 4208 group["timely_by"] = timely_by 4209 4210 # Figure out and apply time phase to group 4211 if now < release_at: 4212 phase = "prerelease" 4213 elif now < timely_by: 4214 phase = "released" 4215 else: 4216 phase = "due" 4217 group["phase"] = phase 4218 4219 # Tracks exercise credit & max for all exercises in the group 4220 group_credit = 0 4221 group_max = 0 4222 4223 # Make allowances for old format 4224 elist = group["exercises"] 4225 if isinstance(elist, dict): 4226 for eid in elist: 4227 elist[eid]['id'] = eid 4228 elist = list(elist.values()) 4229 4230 # Consider each individual exercise in the group 4231 for einfo in elist: 4232 eid = einfo['id'] 4233 # Add to max credit 4234 group_max += 1 4235 4236 # Get info and outcomes 4237 outcomes_here = outcomes.get(eid, {}) 4238 einfo["status"] = outcomes_here.get('status', "unsubmitted") 4239 einfo["on_time"] = outcomes_here.get('on_time', True) 4240 einfo["credit"] = outcomes_here.get('credit', None) 4241 einfo["group_credit"] = outcomes_here.get( 4242 'group_credit', 4243 # Backup in case we're dealing with older data 4244 outcomes_here.get("credit", 0) 4245 ) 4246 4247 # Accumulate credit across the whole group 4248 group_credit += einfo["group_credit"] or 0 4249 4250 # Now that we know the exercise outcomes for each exercise in 4251 # this group, calculate a group status based on the exercise 4252 # outcomes and the group phase. 4253 credit_fraction = group_credit / float(group_max) 4254 if credit_fraction == 1: 4255 status = "perfect" 4256 elif credit_fraction >= usual_config_value( 4257 "EXERCISE_GROUP_THRESHOLD", 4258 task_info, 4259 exercise=group["group"], 4260 default=0.8 4261 ): 4262 status = "complete" 4263 elif credit_fraction >= usual_config_value( 4264 "EXERCISE_GROUP_PARTIAL_THRESHOLD", 4265 task_info, 4266 exercise=group["group"], 4267 default=0.4 4268 ): 4269 status = "partial" 4270 else: 4271 status = "incomplete" 4272 4273 if ( 4274 phase in ("prerelease", "released") 4275 and status not in ("perfect", "complete") 4276 ): 4277 status = "pending" 4278 4279 if phase == "prerelease" and group_credit == 0: 4280 status = "unreleased" 4281 4282 group["status"] = status 4283 group["credit_fraction"] = credit_fraction 4284 4285 # Look up any override and apply it 4286 override = storage.get_egroup_override( 4287 course, 4288 semester, 4289 username, 4290 group["group"] 4291 ) 4292 if override is not None: 4293 if override["override"]: 4294 group["credit_fraction"] = override["override"] 4295 if override["status"]: 4296 group["status"] = override["status"] 4297 if isinstance(override["note"], str) and override["note"] != '': 4298 group["note"] = potluck.render.render_markdown( 4299 override["note"] 4300 )
Amends the exercises in the provided task info based on the given outcomes. It adds time + submission statuses for each exercise, as well as extension info based on the provided course/semester/username.
Each exercise group will gain a "phase" slot with a string indicating which time phase it's in (see the "exercises" entry above). Each group will also gain an "extension" slot that holds a number specifying how many hours of extension the user has been granted for that exercise group, and a "timely_by" slot that holds a datetime object indicating the deadline with any extension factored in.
Also, each individual exercise which has been submitted will gain the following slots:
- a "status" slot with a submission status string (also see above).
- a "credit" slot with a numerical credit value, or None for unsubmitted exercises.
- a "group_credit" slot with a numerical credit value towards group credit, which is usually the same as "credit".
Finally, the exercise groups will gain a "credit_fraction" slot indicating what fraction of credit has been received, and a "status" slot aggregating the statuses of its components, depending on its phase. This group status is determined as follows:
- If all of the exercises in the group are complete, it counts as "perfect".
- If at least 80% (configurable as
EXERCISE_GROUP_THRESHOLD
) of the exercises in the group are complete (counting partially-complete exercises as 1/2) then the group counts as "complete". - If at least
EXERCISE_GROUP_PARTIAL_THRESHOLD
(default 2/5) but less than theEXERCISE_GROUP_THRESHOLD
fraction of the exercises are complete, and the phase is "due" then the status will be "partial". - If a smaller fraction than
EXERCISE_GROUP_PARTIAL_THRESHOLD
of the exercises are complete (still counting partial completions as 1/2) and the phase is "due" then the status will be "incomplete". - If the status would be less than "complete" but the phase is "released" instead of "due" then the status will be "pending" (including when zero exercises have been submitted yet).
- If the phase is "prerelease" then the status will be "unreleased" unless submissions to one or more exercises have already happened, in which case its status will be one of "pending", "complete", or "perfect" depending on how many exercises are complete.
Note: an empty outcomes dictionary may be provided if you only care about setting group phase and extension info and are willing to let credit info be inaccurate.
4303def set_concept_statuses(concepts, task_info, outcomes): 4304 """ 4305 Updates the provided concepts list (whose elements are concept 4306 dictionaries which might have "facets" slots that have sub-concepts 4307 in them) with status info based on the given (amended) task info and 4308 exercise outcomes provided. You must call `amend_task_info`, 4309 `amend_exercises`, and `augment_concepts` before calling this 4310 function. 4311 4312 The concepts dictionary is directly augmented so that each concept 4313 has the following slots: 4314 - "status" holding an aggregate status string 4315 - "outcomes" holding a list of relevant outcomes tuples Each outcome 4316 tuple has an exercise/task-id, a numeric (0-1) outcome value, 4317 and string ('task', 'exercise', or 'outcome') specifying whether 4318 it's an individual outcome or an aggregate outcome from a task 4319 or exercise. 4320 - "exercises" holding a dictionary mapping exercise IDs to 4321 phase/status string pairs for exercises which are relevant to 4322 this concept. The first string represents phase as one of 4323 "prerelease", "released", or "due" to specify the exercise's 4324 release status, and the second is one of "complete", "partial", 4325 "incomplete", or "unsubmitted" to specify the exercise's 4326 submission status. Exercise IDs won't even be in this list if 4327 they haven't been submitted yet. 4328 """ 4329 4330 # Attach outcomes to concepts based on exercise info 4331 for group in task_info.get("exercises", []): 4332 phase = group["phase"] 4333 4334 # Make allowances for old format 4335 elist = group["exercises"] 4336 if isinstance(elist, dict): 4337 for eid in elist: 4338 elist[eid]['id'] = eid 4339 elist = list(elist.values()) 4340 4341 # Consider each individual exercise in the group 4342 for einfo in elist: 4343 eid = einfo['id'] 4344 # Get info, tag, and outcomes 4345 etag = "{}:{}".format(group, eid) 4346 outcomes_here = outcomes.get(eid, {}).get('outcomes', None) 4347 4348 submission_status = einfo["status"] 4349 ecredit = einfo["credit"] 4350 4351 # Note submission status 4352 einfo["status"] = submission_status 4353 4354 # First apply binary credit to each concept for per-outcome 4355 # concepts, and associate per-outcome exercise statuses as 4356 # well. 4357 for i, entry in enumerate(einfo.get("per_outcome", [])): 4358 # Attach individual outcomes to associated per-outcome 4359 # concepts 4360 if ecredit is not None: 4361 # Replace True/False with 1/0: 4362 outcome = outcomes_here[i][:] 4363 outcome[0] = 1 if outcome[0] else 0 4364 oinfo = ( 4365 '{}#{}'.format(etag, i), 4366 1 if outcome[0] else 0, 4367 "outcome" 4368 ) 4369 # Attach outcome 4370 attach_outcome(concepts, oinfo, entry) 4371 outcome_status = ( 4372 "complete" 4373 if outcome[0] 4374 else "incomplete" 4375 ) 4376 else: 4377 outcome_status = submission_status 4378 4379 # Note exercise status for these concepts as well, but 4380 # with success/failure based on individual outcomes 4381 note_exercise_status( 4382 concepts, 4383 eid, 4384 (phase, outcome_status), 4385 entry 4386 ) 4387 4388 # Now apply the exercise credit as an outcome to each 4389 # concept that's set at the exercise level, plus apply 4390 # individual outcomes to their associated per-outcome 4391 # concepts. 4392 for concept_path in einfo.get("concepts", []): 4393 # Note status, potentially overriding per-outcome 4394 # statuses that have already been attached 4395 note_exercise_status( 4396 concepts, 4397 eid, 4398 (phase, submission_status), 4399 concept_path 4400 ) 4401 4402 # Attach exercise-level outcome to exercise-level 4403 # concepts 4404 if ecredit is not None: 4405 try: 4406 attach_outcome( 4407 concepts, 4408 ( 4409 "exercise@{}".format(etag), 4410 ecredit, 4411 "exercise" 4412 ), 4413 concept_path 4414 ) 4415 except ValueError: 4416 raise ValueError( 4417 ( 4418 "In group '{}' exercise '{}' references" 4419 " concept '{}' but that concept doesn't" 4420 " exist." 4421 ).format(group['group'], eid, concept_path) 4422 ) 4423 4424 # TODO: Attach outcomes from project tasks! 4425 4426 # Set concept statuses based on attached outcomes 4427 # TODO: THIS
Updates the provided concepts list (whose elements are concept
dictionaries which might have "facets" slots that have sub-concepts
in them) with status info based on the given (amended) task info and
exercise outcomes provided. You must call amend_task_info
,
amend_exercises
, and augment_concepts
before calling this
function.
The concepts dictionary is directly augmented so that each concept has the following slots:
- "status" holding an aggregate status string
- "outcomes" holding a list of relevant outcomes tuples Each outcome tuple has an exercise/task-id, a numeric (0-1) outcome value, and string ('task', 'exercise', or 'outcome') specifying whether it's an individual outcome or an aggregate outcome from a task or exercise.
- "exercises" holding a dictionary mapping exercise IDs to phase/status string pairs for exercises which are relevant to this concept. The first string represents phase as one of "prerelease", "released", or "due" to specify the exercise's release status, and the second is one of "complete", "partial", "incomplete", or "unsubmitted" to specify the exercise's submission status. Exercise IDs won't even be in this list if they haven't been submitted yet.
4430def all_parents(concepts, concept_path): 4431 """ 4432 Yields each parent of the given concept path, including the target 4433 concept itself, in depth-first order and processing each concept 4434 only once even if parent-loops occur. 4435 4436 Raises a `ValueError` if the target concept cannot be found. 4437 """ 4438 concept = lookup_concept(concepts, concept_path.split(':')) 4439 if concept is None: 4440 raise ValueError( 4441 ( 4442 "Couldn't find parents of concept '{}': that concept" 4443 " does not exist." 4444 ).format(concept_path) 4445 ) 4446 4447 # Process all parents using a stack 4448 seen = set() 4449 stack = [ concept ] 4450 while len(stack) > 0: 4451 this_concept = stack.pop() 4452 4453 # Skip if we've already processed this concept 4454 if this_concept["path"] in seen: 4455 continue 4456 4457 # If we didn't skip, note that we are going to process it 4458 seen.add(this_concept["path"]) 4459 4460 # And yield it 4461 yield this_concept 4462 4463 # Extend stack to include each non-None parent concept 4464 stack.extend( 4465 filter(lambda x: x, this_concept["parents"].values()) 4466 )
Yields each parent of the given concept path, including the target concept itself, in depth-first order and processing each concept only once even if parent-loops occur.
Raises a ValueError
if the target concept cannot be found.
4469def note_exercise_status(concepts, eid, exercise_status, concept_path): 4470 """ 4471 Requires an augmented concepts network, an exercise ID, an exercise 4472 status pair (a tuple containing a time status and a submission 4473 status as strings), and a concept path. 4474 4475 Adds to the "exercises" slot for the specified concept to include 4476 the given exercise status under the given exercise ID, overwriting 4477 any previously-set status. 4478 4479 Applies to parent concepts as well. 4480 4481 Flashes a warning if the target concept cannot be found. 4482 """ 4483 try: 4484 parents = list(all_parents(concepts, concept_path)) 4485 except ValueError as e: 4486 flask.flash( 4487 "Error recording exercise status: {}".format(e) 4488 ) 4489 parents = [] 4490 4491 for concept in parents: 4492 concept.setdefault("exercises", {})[eid] = exercise_status
Requires an augmented concepts network, an exercise ID, an exercise status pair (a tuple containing a time status and a submission status as strings), and a concept path.
Adds to the "exercises" slot for the specified concept to include the given exercise status under the given exercise ID, overwriting any previously-set status.
Applies to parent concepts as well.
Flashes a warning if the target concept cannot be found.
4495def attach_outcome(concepts, outcome_info, concept_path): 4496 """ 4497 Requires an augmented concepts network and an outcome info tuple (a 4498 string naming the outcome plus a number from 0-1 specifying whether 4499 the outcome indicates success, failure, or some kind of partial 4500 success). 4501 4502 Also needs a concept path specifying the concept to which the 4503 outcome applies. Adds the outcome to the list of outcomes relevant 4504 to the target concept, plus the lists for each concept that's a 4505 parent of the target concept. 4506 4507 Flashes a warning message if the target concept cannot be found. 4508 """ 4509 try: 4510 parents = list(all_parents(concepts, concept_path)) 4511 except ValueError as e: 4512 flask.flash( 4513 "Error attaching exercise outcome: {}".format(e) 4514 ) 4515 parents = [] 4516 4517 for concept in parents: 4518 # Add this outcome to the outcomes list for this concept, 4519 # creating it if it hasn't already been created. 4520 concept.setdefault("outcomes", []).append(outcome_info)
Requires an augmented concepts network and an outcome info tuple (a string naming the outcome plus a number from 0-1 specifying whether the outcome indicates success, failure, or some kind of partial success).
Also needs a concept path specifying the concept to which the outcome applies. Adds the outcome to the list of outcomes relevant to the target concept, plus the lists for each concept that's a parent of the target concept.
Flashes a warning message if the target concept cannot be found.
4523def augment_concepts(concepts): 4524 """ 4525 Takes a concepts list (where each entry is a concept dictionary with 4526 an 'id', a 'desc', and possibly 'facets' containing a 4527 sub-concepts-list, OR has just a 'ref' key naming the id-path to a 4528 different concept). Augments that concepts list by replacing all 4529 'ref' entries with actual object references to the named concept, 4530 and by adding the following keys to each non-reference concept: 4531 4532 - 'path': The full reference path for this concept from a top-level 4533 concept, using home concepts over other references to find a way 4534 to the top level. 4535 - 'parents': a dictionary mapping full-id-path-strings to actual 4536 concept dictionaries, with one entry for each concept that 4537 includes this one as a facet. Will be an empty dictionary for 4538 concepts at the top-level that aren't referenced anywhere. If a 4539 concept is at the top level or referenced there, this dictionary 4540 will have a special entry with key `None` and value `None`. 4541 - 'home': A concept dictionary for the natural parent of this 4542 concept: the one parent which included it directly instead of as 4543 a reference. Will be `None` for top-level concepts, including 4544 ones that are referenced somewhere (to avoid this, you can make 4545 a reference at the top level and place the concept you want to 4546 pull up within the place you'd otherwise reference it). 4547 4548 If something is contradictory (e.g., a named reference concept 4549 doesn't exist) a `ValueError` will be raised. Note that all 4550 references must use canonical paths; they cannot 'go through' other 4551 references. 4552 """ 4553 # Create a stack for processing the recursive entries. Each concept 4554 # entry is paired with its natural parent and the index among that 4555 # parent's facets it exists at. 4556 stack = [(concept, None, None) for concept in concepts] 4557 4558 # Continue until we run out of concepts to process 4559 while len(stack) > 0: 4560 # Get the concept + parent + index combo to process 4561 (concept, home, facet_index) = stack.pop() 4562 4563 # Are we a reference? 4564 if 'ref' not in concept: 4565 # Not a reference; augment things & stack up facets 4566 4567 # Set the home for this concept 4568 concept['home'] = home 4569 4570 # Create an empty parents dictionary, or retrieve an 4571 # existing dictionary (possibly created due to a 4572 # previously-processed reference) 4573 parents = concept.setdefault('parents', {}) 4574 4575 # Set path and update parents differently based on whether 4576 # we're at the top level or not 4577 if home is None: 4578 concept['path'] = concept["id"] 4579 parents[None] = None 4580 else: 4581 concept['path'] = home["path"] + ':' + concept["id"] 4582 parents[home['path']] = home 4583 4584 for i, facet in enumerate(concept.get('facets', [])): 4585 stack.append((facet, concept, i)) 4586 4587 else: # if we *are* a reference... 4588 referent = lookup_concept(concepts, concept['ref'].split(':')) 4589 4590 if referent is None: 4591 raise ValueError( 4592 ( 4593 "At '{}', one facet is a reference to '{}', but" 4594 " that concept does not exist. (Note: all" 4595 " references must be via canonical paths, i.e.," 4596 " references may not go through other" 4597 " references.)" 4598 ).format(home['path'], concept['ref']) 4599 ) 4600 4601 # We need to replace ourselves with a real object reference, 4602 # unless we're at the top level 4603 if home is not None: 4604 home['facets'][facet_index] = referent 4605 4606 # We also need to update the parents dictionary of the 4607 # referent to include the parent concept here. 4608 referent.setdefault('parents', {})[home['path']] = home 4609 else: 4610 # Add a 'None' key if this reference is at the top level 4611 referent.setdefault('parents', {})[None] = None 4612 4613 # Note that we *don't* add facets to the stack here! They'll 4614 # be added later after the natural copy of the referent is 4615 # processed.
Takes a concepts list (where each entry is a concept dictionary with an 'id', a 'desc', and possibly 'facets' containing a sub-concepts-list, OR has just a 'ref' key naming the id-path to a different concept). Augments that concepts list by replacing all 'ref' entries with actual object references to the named concept, and by adding the following keys to each non-reference concept:
- 'path': The full reference path for this concept from a top-level concept, using home concepts over other references to find a way to the top level.
- 'parents': a dictionary mapping full-id-path-strings to actual
concept dictionaries, with one entry for each concept that
includes this one as a facet. Will be an empty dictionary for
concepts at the top-level that aren't referenced anywhere. If a
concept is at the top level or referenced there, this dictionary
will have a special entry with key
None
and valueNone
. - 'home': A concept dictionary for the natural parent of this
concept: the one parent which included it directly instead of as
a reference. Will be
None
for top-level concepts, including ones that are referenced somewhere (to avoid this, you can make a reference at the top level and place the concept you want to pull up within the place you'd otherwise reference it).
If something is contradictory (e.g., a named reference concept
doesn't exist) a ValueError
will be raised. Note that all
references must use canonical paths; they cannot 'go through' other
references.
4618def lookup_concept(concepts, concept_names): 4619 """ 4620 Based on a sequence of concept-name strings, returns the associated 4621 concept dictionary from the given top-level concepts list. This will 4622 only be able to find concepts via their natural parents, unless the 4623 concepts list has been augmented. 4624 4625 Returns `None` if there is no such concept. 4626 """ 4627 first = concept_names[0] 4628 match = None 4629 for concept in concepts: 4630 if concept["id"] == first: 4631 match = concept 4632 break 4633 4634 if match is None: 4635 return None 4636 elif len(concept_names) == 1: 4637 return match 4638 else: 4639 return lookup_concept(match.get('facets', []), concept_names[1:])
Based on a sequence of concept-name strings, returns the associated concept dictionary from the given top-level concepts list. This will only be able to find concepts via their natural parents, unless the concepts list has been augmented.
Returns None
if there is no such concept.
4642def percentile(dataset, pct): 4643 """ 4644 Computes the nth percentile of the dataset by a weighted average of 4645 the two items on either side of that fractional index within the 4646 dataset. pct must be a number between 0 and 100 (inclusive). 4647 4648 Returns None when given an empty dataset, and always returns the 4649 singular item in the dataset when given a dataset of length 1. 4650 """ 4651 fr = pct / 100.0 4652 if len(dataset) == 1: 4653 return dataset[0] 4654 elif len(dataset) == 0: 4655 return None 4656 srt = sorted(dataset) 4657 fridx = fr * (len(srt) - 1) 4658 idx = int(fridx) 4659 if idx == fridx: 4660 return srt[idx] # integer index -> no averaging 4661 leftover = fridx - idx 4662 first = srt[idx] 4663 second = srt[idx + 1] # legal index because we can't have hit the end 4664 return first * (1 - leftover) + second * leftover
Computes the nth percentile of the dataset by a weighted average of the two items on either side of that fractional index within the dataset. pct must be a number between 0 and 100 (inclusive).
Returns None when given an empty dataset, and always returns the singular item in the dataset when given a dataset of length 1.
4667def get_feedback_pr_and_task( 4668 task_info, 4669 course, 4670 semester, 4671 user, 4672 phase, 4673 prid, 4674 taskid 4675): 4676 """ 4677 Given a task_info object and a particular 4678 course/semester/user/phase/project/task we're interested in, 4679 extracts and augments pr and task objects to make them ready for 4680 rendering as feedback, returning a tuple of both. 4681 4682 Returns a ValueError object in cases where the requested 4683 project/task doesn't exist. 4684 """ 4685 4686 # Extract pr & task objects 4687 try: 4688 pr = get_pr_obj(task_info, prid) 4689 except ValueError as e: 4690 return e 4691 4692 try: 4693 task = get_task_obj(task_info, pr, taskid) 4694 except ValueError as e: 4695 return e 4696 4697 # Add status & time remaining info to project and objects 4698 amend_project(course, semester, task_info, pr, user) 4699 amend_task(course, semester, task_info, prid, task, user, phase) 4700 4701 # Get full feedback for the task in question if it's available 4702 task["feedback"] = storage.get_feedback( 4703 course, 4704 semester, 4705 task_info, 4706 user, 4707 phase, 4708 pr["id"], 4709 task["id"] 4710 ) 4711 # Get HTML feedback as well 4712 task["feedback_html"] = storage.get_feedback_html( 4713 course, 4714 semester, 4715 task_info, 4716 user, 4717 phase, 4718 pr["id"], 4719 task["id"] 4720 ) 4721 if task["feedback"]["status"] != "missing": 4722 task["submitted"] = True 4723 potluck.render.augment_report(task["feedback"]) 4724 4725 return pr, task
Given a task_info object and a particular course/semester/user/phase/project/task we're interested in, extracts and augments pr and task objects to make them ready for rendering as feedback, returning a tuple of both.
Returns a ValueError object in cases where the requested project/task doesn't exist.
4728def get_evaluation_info( 4729 course, 4730 semester, 4731 target_user, 4732 phase, 4733 prid, 4734 taskid 4735): 4736 """ 4737 Fetches notes and override info for the given submission, and returns 4738 a tuple including the notes markdown source, the notes rendered HTML, 4739 the grade override value, and the timeliness override value. 4740 4741 The grade override will be an empty string if no override is active, 4742 and should be a floating-point value otherwise except in cases where 4743 a non-numeric value was set. 4744 4745 The timeliness override is similar, and should only ever be set for 4746 the 'initial' phase, since it applies across all phases. 4747 4748 The notes and notes HTML strings will be empty strings if no notes 4749 have been set. 4750 """ 4751 # Fetch raw eval info dict 4752 evaluation = storage.fetch_evaluation( 4753 course, 4754 semester, 4755 target_user, 4756 phase, 4757 prid, 4758 taskid 4759 ) 4760 4761 if evaluation is None: 4762 return '', '', '', '' 4763 4764 # Extract notes and grade override from stored info 4765 notes = evaluation.get("notes", "") 4766 override = evaluation.get("override") 4767 if override is None: 4768 override = "" 4769 else: 4770 try: 4771 override = float(override) 4772 except Exception: 4773 pass 4774 4775 timeliness = evaluation.get("timeliness") 4776 if timeliness is None: 4777 timeliness = "" 4778 else: 4779 try: 4780 timeliness = float(timeliness) 4781 except Exception: 4782 pass 4783 4784 # Render notes as HTML 4785 notes_html = potluck.render.render_markdown(notes) 4786 4787 return notes, notes_html, override, timeliness
Fetches notes and override info for the given submission, and returns a tuple including the notes markdown source, the notes rendered HTML, the grade override value, and the timeliness override value.
The grade override will be an empty string if no override is active, and should be a floating-point value otherwise except in cases where a non-numeric value was set.
The timeliness override is similar, and should only ever be set for the 'initial' phase, since it applies across all phases.
The notes and notes HTML strings will be empty strings if no notes have been set.
4800def project_status_now( 4801 username, 4802 task_info, 4803 project_obj, 4804 extensions=(False, False) 4805): 4806 """ 4807 Returns the current state of the given project object (also needs 4808 the task info object and the username). If "PAUSE_AT" is set in the 4809 task object and non-empty (see set_pause_time), that moment, not the 4810 current time, will be used. Extensions must contain two values (one 4811 for the initial phase and one for the revision phase). They may each 4812 be False for no extension, True for the default extension, or an 4813 integer number of hours. Those hours will be added to the effective 4814 initial and revision deadlines. 4815 4816 Returns a dictionary with 'state', 'initial-extension', 4817 'revision-extension', 'release', 'due', 'reviewed', and 'finalized', 4818 keys. Each of the 'release', 'due', 'reviewed', and 'finalized' keys 4819 will be a sub-dictionary with the following keys: 4820 4821 - 'at': A dateimte.datetime object specifying absolute timing. 4822 - 'at_str': A string representation of the above. 4823 - 'until': A datetime.timedelta representing time until the event (will 4824 be negative afterwards). 4825 - 'until_str': A string representation of the above. 4826 4827 Each of the sub-values will be none if the project doesn't have a 4828 deadline set. 4829 4830 The 'state' value will be one of: 4831 4832 - "unreleased": This project hasn't yet been released; don't display 4833 any info about it. 4834 - "released": This project has been released and isn't due yet. 4835 - "under_review": This project's due time has passed, but the feedback 4836 review period hasn't expired yet. 4837 - "revisable": This project's due time is past, and the review period has 4838 expired, so full feedback should be released, but revisions may 4839 still be submitted. 4840 - "final": This project's due time is past, the review period has 4841 expired, and the revision period is also over, so full feedback 4842 is available, and no more submissions will be accepted. 4843 - "unknown": This project doesn't have a due date. The 4844 seconds_remaining value will be None. 4845 4846 The 'initial_extension' and 'revision_extension' will both be numbers 4847 specifying how many hours of extension were granted (these numbers 4848 are already factored into the deadline information the status 4849 contains). These numbers will be 0 for students who don't have 4850 extensions. 4851 """ 4852 # Get extension/revision durations and grace period from task info: 4853 standard_ext_hrs = task_info.get("extension_hours", 24) 4854 review_hours = task_info.get("review_hours", 24) 4855 grace_mins = task_info.get("grace_minutes", 0) 4856 revision_hours = task_info.get("revision_hours", 72) 4857 4858 # Get project-specific review/grace/revision info if it exists 4859 review_hours = project_obj.get("review_hours", review_hours) 4860 grace_mins = project_obj.get("grace_minutes", grace_mins) 4861 revision_hours = project_obj.get("revision_hours", revision_hours) 4862 4863 # Figure out extension amounts 4864 initial_extension = 0 4865 if extensions[0] is True: 4866 initial_extension = standard_ext_hrs 4867 elif isinstance(extensions[0], (int, float)): 4868 initial_extension = extensions[0] 4869 elif extensions[0] is not False: 4870 flask.flash( 4871 "Ignoring invalid initial extension value '{}'".format( 4872 extensions[0] 4873 ) 4874 ) 4875 4876 revision_extension = 0 4877 if extensions[1] is True: 4878 revision_extension = standard_ext_hrs 4879 elif isinstance(extensions[1], (int, float)): 4880 revision_extension = extensions[1] 4881 elif extensions[1] is not False: 4882 flask.flash( 4883 "Ignoring invalid revision extension value '{}'".format( 4884 extensions[1] 4885 ) 4886 ) 4887 4888 # The default result 4889 result = { 4890 'state': "unknown", 4891 'release': { 4892 'at': None, 4893 'at_str': 'unknown', 4894 'until': None, 4895 'until_str': 'at some point (not yet specified)' 4896 }, 4897 'due': { 4898 'at': None, 4899 'at_str': 'unknown', 4900 'until': None, 4901 'until_str': 'at some point (not yet specified)' 4902 }, 4903 'reviewed': { 4904 'at': None, 4905 'at_str': 'unknown', 4906 'until': None, 4907 'until_str': 'at some point (not yet specified)' 4908 }, 4909 'finalized': { 4910 'at': None, 4911 'at_str': 'unknown', 4912 'until': None, 4913 'until_str': 'at some point (not yet specified)' 4914 }, 4915 'initial_extension': 0, 4916 'revision_extension': 0, 4917 } 4918 4919 # Save extension info 4920 result['initial_extension'] = initial_extension or 0 4921 result['revision_extension'] = revision_extension or 0 4922 4923 # Get current time: 4924 if "PAUSE_AT" in task_info and task_info["PAUSE_AT"]: 4925 now = potluck.time_utils.task_time__time( 4926 task_info, 4927 task_info["PAUSE_AT"] 4928 ) 4929 else: 4930 now = potluck.time_utils.now() 4931 4932 # Get release date/time: 4933 release_at = project_obj.get("release", None) 4934 # if None, we assume release 4935 if release_at is not None: 4936 release_at = potluck.time_utils.task_time__time( 4937 task_info, 4938 release_at, 4939 default_time_of_day=task_info.get( 4940 "default_release_time_of_day", 4941 "23:59" 4942 ) 4943 ) 4944 # Fill in release info 4945 result['release']['at'] = release_at 4946 result['release']['at_str'] = potluck.time_utils.fmt_datetime( 4947 release_at 4948 ) 4949 until_release = release_at - now 4950 result['release']['until'] = until_release 4951 result['release']['until_str'] = fuzzy_time( 4952 until_release.total_seconds() 4953 ) 4954 4955 # Get due date/time: 4956 due_at = project_obj.get("due", None) 4957 if due_at is None: 4958 # Return empty result 4959 return result 4960 else: 4961 due_at = potluck.time_utils.task_time__time( 4962 task_info, 4963 due_at, 4964 default_time_of_day=task_info.get( 4965 "default_due_time_of_day", 4966 "23:59" 4967 ) 4968 ) 4969 review_end = due_at + datetime.timedelta(hours=review_hours) 4970 4971 due_string = potluck.time_utils.fmt_datetime(due_at) 4972 4973 base_deadline = due_at 4974 4975 # Add extension hours: 4976 if initial_extension > 0: 4977 due_at += datetime.timedelta(hours=initial_extension) 4978 due_string = potluck.time_utils.fmt_datetime(due_at) + ( 4979 ' <span class="extension_taken">' 4980 + '(after accounting for your {}‑hour extension)' 4981 + '</span>' 4982 ).format(initial_extension) 4983 4984 grace_deadline = due_at + datetime.timedelta(minutes=grace_mins) 4985 4986 # Fill in due info 4987 result['due']['at'] = due_at 4988 result['due']['at_str'] = due_string 4989 until_due = due_at - now 4990 result['due']['until'] = until_due 4991 result['due']['until_str'] = fuzzy_time(until_due.total_seconds()) 4992 4993 # Fill in review info 4994 result['reviewed']['at'] = review_end 4995 result['reviewed']['at_str'] = potluck.time_utils.fmt_datetime( 4996 review_end 4997 ) 4998 until_reviewed = review_end - now 4999 result['reviewed']['until'] = until_reviewed 5000 result['reviewed']['until_str'] = fuzzy_time( 5001 until_reviewed.total_seconds() 5002 ) 5003 5004 # Get final date/time: 5005 # Note: any extension to the initial deadline is ignored. A separate 5006 # revision extension should be issued when an initial extension eats 5007 # up too much of the revision period. 5008 final_at = base_deadline + datetime.timedelta( 5009 hours=review_hours + revision_hours 5010 ) 5011 5012 final_string = potluck.time_utils.fmt_datetime(final_at) 5013 5014 # Add extension hours: 5015 if revision_extension > 0: 5016 final_at += datetime.timedelta(hours=revision_extension) 5017 final_string = potluck.time_utils.fmt_datetime(final_at) + ( 5018 ' <span class="extension_taken">' 5019 + '(after accounting for your {}‑hour extension)' 5020 + '</span>' 5021 ).format(revision_extension) 5022 5023 grace_final = final_at + datetime.timedelta(minutes=grace_mins) 5024 5025 # Fill in finalization info 5026 result['finalized']['at'] = final_at 5027 result['finalized']['at_str'] = final_string 5028 until_final = final_at - now 5029 result['finalized']['until'] = until_final 5030 result['finalized']['until_str'] = fuzzy_time(until_final.total_seconds()) 5031 5032 # Check release time: 5033 if release_at and now < release_at: 5034 result['state'] = "unreleased" 5035 # Passed release_at point: check if it's due or not 5036 elif now < grace_deadline: 5037 # Note time-remaining ignores grace period and may be negative 5038 result['state'] = "released" 5039 # Passed due_at point; check if it's still under review: 5040 elif now < review_end: 5041 result['state'] = "under_review" 5042 # Passed review period: are revisions still being accepted? 5043 elif now < grace_final: 5044 result['state'] = "revisable" 5045 # Passed review period: it's final 5046 else: 5047 result['state'] = "final" 5048 5049 return result
Returns the current state of the given project object (also needs the task info object and the username). If "PAUSE_AT" is set in the task object and non-empty (see set_pause_time), that moment, not the current time, will be used. Extensions must contain two values (one for the initial phase and one for the revision phase). They may each be False for no extension, True for the default extension, or an integer number of hours. Those hours will be added to the effective initial and revision deadlines.
Returns a dictionary with 'state', 'initial-extension', 'revision-extension', 'release', 'due', 'reviewed', and 'finalized', keys. Each of the 'release', 'due', 'reviewed', and 'finalized' keys will be a sub-dictionary with the following keys:
- 'at': A dateimte.datetime object specifying absolute timing.
- 'at_str': A string representation of the above.
- 'until': A datetime.timedelta representing time until the event (will be negative afterwards).
- 'until_str': A string representation of the above.
Each of the sub-values will be none if the project doesn't have a deadline set.
The 'state' value will be one of:
- "unreleased": This project hasn't yet been released; don't display any info about it.
- "released": This project has been released and isn't due yet.
- "under_review": This project's due time has passed, but the feedback review period hasn't expired yet.
- "revisable": This project's due time is past, and the review period has expired, so full feedback should be released, but revisions may still be submitted.
- "final": This project's due time is past, the review period has expired, and the revision period is also over, so full feedback is available, and no more submissions will be accepted.
- "unknown": This project doesn't have a due date. The seconds_remaining value will be None.
The 'initial_extension' and 'revision_extension' will both be numbers specifying how many hours of extension were granted (these numbers are already factored into the deadline information the status contains). These numbers will be 0 for students who don't have extensions.
5056def get_submission_filename( 5057 course, 5058 semester, 5059 task_info, 5060 username, 5061 phase, 5062 prid, 5063 taskid 5064): 5065 """ 5066 Returns the filename for the user's submission for a given 5067 phase/project/task. Raises a ValueError if the project or task 5068 doesn't exit. 5069 5070 TODO: Do we just do zip files for multi-file tasks? How is that 5071 handled? 5072 """ 5073 pr = get_pr_obj(task_info, prid) 5074 task = get_task_obj(task_info, pr, taskid) 5075 5076 return safe_join( 5077 storage.submissions_folder(course, semester), 5078 username, 5079 "{}_{}_{}".format( 5080 prid, 5081 phase, 5082 task["target"] 5083 ) 5084 )
Returns the filename for the user's submission for a given phase/project/task. Raises a ValueError if the project or task doesn't exit.
TODO: Do we just do zip files for multi-file tasks? How is that handled?
5094@app.template_filter() 5095def sorted(*args, **kwargs): 5096 """ 5097 Turn builtin sorted into a template filter... 5098 """ 5099 return _sorted(*args, **kwargs)
Turn builtin sorted into a template filter...
5102@app.template_filter() 5103def fuzzy_time(seconds): 5104 """ 5105 Takes a number of seconds and returns a fuzzy time value that shifts 5106 units (up to weeks) depending on how many seconds there are. Ignores 5107 the sign of the value. 5108 """ 5109 if seconds < 0: 5110 seconds = -seconds 5111 5112 weeks = seconds / ONE_WEEK 5113 seconds %= ONE_WEEK 5114 days = seconds / ONE_DAY 5115 seconds %= ONE_DAY 5116 hours = seconds / ONE_HOUR 5117 seconds %= ONE_HOUR 5118 minutes = seconds / ONE_MINUTE 5119 seconds %= ONE_MINUTE 5120 if int(weeks) > 1: 5121 if weeks % 1 > 0.75: 5122 return "almost {:.0f} weeks".format(weeks + 1) 5123 else: 5124 return "{:.0f} weeks".format(weeks) 5125 elif int(weeks) == 1: 5126 return "{:.0f} days".format(7 + days) 5127 elif int(days) > 1: 5128 if days % 1 > 0.75: 5129 return "almost {:.0f} days".format(days + 1) 5130 else: 5131 return "{:.0f} days".format(days) 5132 elif int(days) == 1: 5133 return "{:.0f} hours".format(24 + hours) 5134 elif hours > 4: 5135 if hours % 1 > 0.75: 5136 return "almost {:.0f} hours".format(hours + 1) 5137 else: 5138 return "{:.0f} hours".format(hours) 5139 elif int(hours) > 0: 5140 return "{:.0f}h {:.0f}m".format(hours, minutes) 5141 elif minutes > 30: 5142 return "{:.0f} minutes".format(minutes) 5143 else: 5144 return "{:.0f}m {:.0f}s".format(minutes, seconds)
Takes a number of seconds and returns a fuzzy time value that shifts units (up to weeks) depending on how many seconds there are. Ignores the sign of the value.
5147@app.template_filter() 5148def timestamp(value): 5149 """ 5150 A filter to display a timestamp. 5151 """ 5152 dt = potluck.time_utils.time_from_timestring(value) 5153 return potluck.time_utils.fmt_datetime(dt)
A filter to display a timestamp.
5156@app.template_filter() 5157def seconds(timedelta): 5158 """ 5159 Converts a timedelta to a floating-point number of seconds. 5160 """ 5161 return timedelta.total_seconds()
Converts a timedelta to a floating-point number of seconds.
5164@app.template_filter() 5165def integer(value): 5166 """ 5167 A filter to display a number as an integer. 5168 """ 5169 if isinstance(value, (float, int)): 5170 return str(round(value)) 5171 else: 5172 return str(value)
A filter to display a number as an integer.
5189@app.template_filter() 5190def a_an(h): 5191 """ 5192 Returns the string 'a' or 'an' where the use of 'a/an' depends on the 5193 first letter of the name of the first digit of the given number, or 5194 the first letter of the given string. 5195 5196 Can't handle everything because it doesn't know phonetics (e.g., 'a 5197 hour' not 'an hour' because 'h' is not a vowel). 5198 """ 5199 digits = str(h) 5200 fd = digits[0] 5201 if fd in "18aeiou": 5202 return 'an' 5203 else: 5204 return 'a'
Returns the string 'a' or 'an' where the use of 'a/an' depends on the first letter of the name of the first digit of the given number, or the first letter of the given string.
Can't handle everything because it doesn't know phonetics (e.g., 'a hour' not 'an hour' because 'h' is not a vowel).
5207@app.template_filter() 5208def project_combined_grade(project, task_info=None): 5209 """ 5210 Extracts a full combined grade value from a project object. Respects 5211 task weights; fills in zeroes for any missing grades, and grabs the 5212 highest score from each task pool. Includes timeliness points along 5213 with task grades, re-normalizing to be out of the `SCORE_BASIS`. 5214 5215 If `task_info` is provided a `SCORE_BASIS` default may be picked up 5216 from there if the project doesn't define one. If not, only the app 5217 global config can define `SCORE_BASIS` if one isn't specified within 5218 the project itself. 5219 """ 5220 pool_scores = {} 5221 for task in project["tasks"]: 5222 # Get a grade & weight 5223 cg = task_combined_grade(task, task_info) 5224 tp = task_timeliness_points(task, task_info) 5225 tw = task.get("weight", 1) 5226 if cg is None: 5227 cg = 0 5228 5229 new_score = float(cg + tp) # float just in case... 5230 5231 # Figure out this task's pool and update the score for that pool 5232 pool = task_pool(task) 5233 if pool in pool_scores: 5234 old_score, old_weight = pool_scores[pool] 5235 if old_weight != tw: 5236 raise ValueError("Inconsistent weights for pooled tasks!") 5237 if old_score < new_score: 5238 pool_scores[pool] = [new_score, tw] 5239 else: 5240 pool_scores[pool] = [new_score, tw] 5241 5242 score_basis = fallback_config_value( 5243 "SCORE_BASIS", 5244 project, 5245 task_info or {}, 5246 app.config, 5247 DEFAULT_CONFIG 5248 ) 5249 max_score = score_basis + fallback_config_value( 5250 "TIMELINESS_POINTS", 5251 project, 5252 task_info or {}, 5253 app.config, 5254 DEFAULT_CONFIG 5255 ) 5256 weighted_score = sum( 5257 (grade / float(max_score)) * weight 5258 for grade, weight in pool_scores.values() 5259 ) 5260 total_weight = sum(weight for grade, weight in pool_scores.values()) 5261 5262 return score_basis * weighted_score / float(total_weight)
Extracts a full combined grade value from a project object. Respects
task weights; fills in zeroes for any missing grades, and grabs the
highest score from each task pool. Includes timeliness points along
with task grades, re-normalizing to be out of the SCORE_BASIS
.
If task_info
is provided a SCORE_BASIS
default may be picked up
from there if the project doesn't define one. If not, only the app
global config can define SCORE_BASIS
if one isn't specified within
the project itself.
5265@app.template_filter() 5266def uses_pools(project): 5267 """ 5268 Returns True if the project has at least two tasks that are in the same 5269 pool, and False otherwise. 5270 """ 5271 pools = set(task_pool(task) for task in project["tasks"]) 5272 return len(pools) < len(project["tasks"])
Returns True if the project has at least two tasks that are in the same pool, and False otherwise.
5275@app.template_filter() 5276def task_pool(task): 5277 """ 5278 Grabs the pool for a task, which defaults to the task ID. 5279 """ 5280 return task.get("pool", task["id"])
Grabs the pool for a task, which defaults to the task ID.
5283@app.template_filter() 5284def project_pools(project): 5285 """ 5286 Returns a list of pairs, each containing a pool ID and a colspan 5287 integer for that pool. 5288 """ 5289 seen = set() 5290 result = [] 5291 for task in project["tasks"]: 5292 pool = task_pool(task) 5293 if pool in seen: 5294 continue 5295 else: 5296 seen.add(pool) 5297 result.append((pool, pool_colspan(project, task["id"]))) 5298 return result
Returns a list of pairs, each containing a pool ID and a colspan integer for that pool.
5301@app.template_filter() 5302def pool_colspan(project, taskid): 5303 """ 5304 Returns the column span for the pool of the given task in the given 5305 project, assuming that the tasks of the project are displayed in order. 5306 """ 5307 start_at = None 5308 this_task = None 5309 for i in range(len(project["tasks"])): 5310 if project["tasks"][i]["id"] == taskid: 5311 start_at = i 5312 this_task = project["tasks"][i] 5313 break 5314 5315 if start_at is None: 5316 raise ValueError( 5317 "Pset '{}' does not contain a task '{}'.".format( 5318 project["id"], 5319 taskid 5320 ) 5321 ) 5322 5323 this_pool = task_pool(this_task) 5324 span = 1 5325 for task in project["tasks"][i + 1:]: 5326 if task_pool(task) == this_pool: 5327 span += 1 5328 else: 5329 break # span is over 5330 5331 return span
Returns the column span for the pool of the given task in the given project, assuming that the tasks of the project are displayed in order.
5334@app.template_filter() 5335def task_combined_grade(task, task_info=None): 5336 """ 5337 Extracts the combined grade value between initial/revised/belated 5338 submissions for the given task. Returns a point number, or None if 5339 there is not enough information to establish a grade. 5340 5341 Timeliness points are not included (see `task_timeliness_points`). 5342 5343 If `task_info` is provided, then defaults for things like the score 5344 basis or revision score limits may be pulled from that, but otherwise 5345 either the task itself or the app config determine these. 5346 """ 5347 base_grade = task.get("grade") 5348 options = [] 5349 if base_grade is not None and base_grade != "?": 5350 options.append(float(base_grade)) 5351 5352 rev_task = task.get("revision", {}) 5353 rev_grade = rev_task.get("grade") 5354 if isinstance(rev_grade, (int, float)): 5355 rmax = fallback_config_value( 5356 "REVISION_MAX_SCORE", 5357 task, 5358 task_info or {}, 5359 app.config, 5360 DEFAULT_CONFIG 5361 ) 5362 if rmax is NotFound: 5363 rmax = fallback_config_value( 5364 "SCORE_BASIS", 5365 task, 5366 task_info or {}, 5367 app.config, 5368 DEFAULT_CONFIG 5369 ) 5370 if rmax is NotFound: 5371 rmax = 100 5372 options.append(min(rev_grade, rev_task.get("max_score", rmax))) 5373 5374 belated_task = task.get("belated", {}) 5375 belated_grade = belated_task.get("grade") 5376 if isinstance(belated_grade, (int, float)): 5377 bmax = fallback_config_value( 5378 "BELATED_MAX_SCORE", 5379 task, 5380 task_info or {}, 5381 app.config, 5382 DEFAULT_CONFIG 5383 ) 5384 if bmax is NotFound: 5385 bmax = fallback_config_value( 5386 "SCORE_BASIS", 5387 task, 5388 task_info or {}, 5389 app.config, 5390 DEFAULT_CONFIG 5391 ) 5392 if bmax is NotFound: 5393 bmax = 100 5394 options.append( 5395 min(belated_grade, belated_task.get("max_score", bmax)) 5396 ) 5397 5398 if len(options) > 0: 5399 return max(options) 5400 else: 5401 return None
Extracts the combined grade value between initial/revised/belated submissions for the given task. Returns a point number, or None if there is not enough information to establish a grade.
Timeliness points are not included (see task_timeliness_points
).
If task_info
is provided, then defaults for things like the score
basis or revision score limits may be pulled from that, but otherwise
either the task itself or the app config determine these.
5404@app.template_filter() 5405def task_timeliness_points(task, task_info=None): 5406 """ 5407 Returns a number indicating how many timeliness points were earned 5408 for submissions to the given task. The `TIMELINESS_POINTS` value 5409 determines how many points are available in total; half of these are 5410 awarded if a submission is made by the initial deadline which earns 5411 at least `TIMELINESS_ATTEMPT_THRESHOLD` points, and the other half 5412 are earned if a submission is made by the revision deadline which 5413 earns at least `TIMELINESS_COMPLETE_THRESHOLD` points. 5414 5415 Config values are pulled from the provided `task_info` object if 5416 there is one; otherwise they come from the task itself or from the 5417 app-wide config, with the `DEFAULT_CONFIG` as a final backup. 5418 5419 A manual override may also have been provided, and is used if so. 5420 5421 TODO: This really needs to be upgraded at some point to respect the 5422 best submission in each phase, rather than just the latest. Students 5423 who accidentally downgrade their evaluation may lose timeliness 5424 points that they really should have earned! 5425 """ 5426 earned = 0 5427 available = fallback_config_value( 5428 "TIMELINESS_POINTS", 5429 task, 5430 task_info or {}, 5431 app.config, 5432 DEFAULT_CONFIG 5433 ) 5434 timely_attempt_threshold = fallback_config_value( 5435 "TIMELINESS_ATTEMPT_THRESHOLD", 5436 task, 5437 task_info or {}, 5438 app.config, 5439 DEFAULT_CONFIG 5440 ) 5441 if timely_attempt_threshold is NotFound: 5442 timely_attempt_threshold = fallback_config_value( 5443 ["EVALUATION_SCORES", "partially complete"], 5444 task, 5445 task_info or {}, 5446 app.config, 5447 DEFAULT_CONFIG 5448 ) 5449 if timely_attempt_threshold is NotFound: 5450 timely_attempt_threshold = 75 5451 completion_threshold = fallback_config_value( 5452 "TIMELINESS_COMPLETE_THRESHOLD", 5453 task, 5454 task_info or {}, 5455 app.config, 5456 DEFAULT_CONFIG 5457 ) 5458 if timely_attempt_threshold is NotFound: 5459 timely_attempt_threshold = fallback_config_value( 5460 ["EVALUATION_SCORES", "almost complete"], 5461 task, 5462 task_info or {}, 5463 app.config, 5464 DEFAULT_CONFIG 5465 ) 5466 if timely_attempt_threshold is NotFound: 5467 timely_attempt_threshold = 85 5468 5469 if available is NotFound: 5470 available = 0 5471 attempt_points = available / 2 5472 if attempt_points == 0: 5473 attempt_points = available / 2.0 5474 completion_points = available - attempt_points 5475 5476 if task.get("timeliness_overridden") and "timeliness" in task: 5477 return task["timeliness"] 5478 5479 initial_grade = task.get("grade") 5480 if initial_grade == "?": 5481 initial_grade = None 5482 elif initial_grade is not None: 5483 initial_grade = float(initial_grade) 5484 if initial_grade >= timely_attempt_threshold: 5485 earned += attempt_points 5486 5487 rev_task = task.get("revision") 5488 has_rev_grade = ( 5489 rev_task 5490 and "grade" in rev_task 5491 and rev_task["grade"] not in (None, "?") 5492 ) 5493 5494 if ( 5495 (initial_grade is not None and initial_grade >= completion_threshold) 5496 or (has_rev_grade and float(rev_task["grade"]) >= completion_threshold) 5497 ): 5498 earned += completion_points 5499 5500 return earned
Returns a number indicating how many timeliness points were earned
for submissions to the given task. The TIMELINESS_POINTS
value
determines how many points are available in total; half of these are
awarded if a submission is made by the initial deadline which earns
at least TIMELINESS_ATTEMPT_THRESHOLD
points, and the other half
are earned if a submission is made by the revision deadline which
earns at least TIMELINESS_COMPLETE_THRESHOLD
points.
Config values are pulled from the provided task_info
object if
there is one; otherwise they come from the task itself or from the
app-wide config, with the DEFAULT_CONFIG
as a final backup.
A manual override may also have been provided, and is used if so.
TODO: This really needs to be upgraded at some point to respect the best submission in each phase, rather than just the latest. Students who accidentally downgrade their evaluation may lose timeliness points that they really should have earned!
5503@app.template_filter() 5504def ex_combined_grade(egroup, task_info=None): 5505 """ 5506 Extracts a full combined grade value from an amended exercise group 5507 object. Uses the pre-calculated credit-fraction and adds bonus 5508 points for reaching partial/complete thresholds. 5509 5510 Scores are not rounded (use `grade_string` or the like). 5511 5512 Config values are taken from the given `task_info` object as a backup 5513 to values in the exercise group itself if one is provided. Otherwise 5514 only the app config and `DEFAULT_CONFIG` may specify them. 5515 """ 5516 fraction = egroup["credit_fraction"] 5517 5518 # free credit bump for hitting each threshold 5519 bump = fallback_config_value( 5520 "EXERCISE_GROUP_CREDIT_BUMP", 5521 egroup, 5522 task_info or {}, 5523 app.config, 5524 DEFAULT_CONFIG 5525 ) 5526 if bump is NotFound: 5527 bump = 0.1 5528 5529 partial_threshold = fallback_config_value( 5530 "EXERCISE_GROUP_PARTIAL_THRESHOLD", 5531 egroup, 5532 task_info or {}, 5533 app.config, 5534 DEFAULT_CONFIG 5535 ) 5536 if partial_threshold is NotFound: 5537 partial_threshold = 0.395 5538 5539 full_threshold = fallback_config_value( 5540 "EXERCISE_GROUP_THRESHOLD", 5541 egroup, 5542 task_info or {}, 5543 app.config, 5544 DEFAULT_CONFIG 5545 ) 5546 if full_threshold is NotFound: 5547 full_threshold = 0.795 5548 5549 score_basis = fallback_config_value( 5550 "SCORE_BASIS", 5551 egroup, 5552 task_info or {}, 5553 app.config, 5554 DEFAULT_CONFIG 5555 ) 5556 if score_basis is NotFound: 5557 score_basis = 100 5558 5559 if fraction >= partial_threshold: 5560 fraction += bump 5561 if fraction >= full_threshold: 5562 fraction += bump 5563 5564 # no extra credit 5565 return min(score_basis, score_basis * fraction)
Extracts a full combined grade value from an amended exercise group object. Uses the pre-calculated credit-fraction and adds bonus points for reaching partial/complete thresholds.
Scores are not rounded (use grade_string
or the like).
Config values are taken from the given task_info
object as a backup
to values in the exercise group itself if one is provided. Otherwise
only the app config and DEFAULT_CONFIG
may specify them.
5568@app.template_filter() 5569def grade_string(grade_value, task_info=None, local_info=None): 5570 """ 5571 Turns a grade value (None, or a number) into a grade string (an HTML 5572 string w/ a denominator, or 'unknown'). The rounding preference and 5573 score basis are pulled from the given `task_info` object, or if a 5574 `local_info` object is provided comes from there preferentially. If 5575 neither is available, it will pull from the app config or the 5576 `DEFAULT_CONFIG` values. 5577 """ 5578 basis = fallback_config_value( 5579 "SCORE_BASIS", 5580 local_info or {}, 5581 task_info or {}, 5582 app.config, 5583 DEFAULT_CONFIG 5584 ) 5585 if basis is NotFound: 5586 basis = 100 5587 5588 round_to = fallback_config_value( 5589 "ROUND_SCORES_TO", 5590 local_info or {}, 5591 task_info or {}, 5592 app.config, 5593 DEFAULT_CONFIG 5594 ) 5595 if round_to is NotFound: 5596 round_to = 1 5597 5598 if grade_value is None or not isinstance(grade_value, (int, float)): 5599 return "unknown" 5600 else: 5601 rounded = round(grade_value, round_to) 5602 rdenom = round(basis, round_to) 5603 if int(rounded) == rounded: 5604 rounded = int(rounded) 5605 if int(rdenom) == rdenom: 5606 rdenom = int(rdenom) 5607 return "{} / {}".format(rounded, rdenom)
Turns a grade value (None, or a number) into a grade string (an HTML
string w/ a denominator, or 'unknown'). The rounding preference and
score basis are pulled from the given task_info
object, or if a
local_info
object is provided comes from there preferentially. If
neither is available, it will pull from the app config or the
DEFAULT_CONFIG
values.
5610@app.template_filter() 5611def timeliness_string(grade_value, task_info=None, local_info=None): 5612 """ 5613 Turns a timeliness points value (None, or a number) into a timeliness 5614 grade string (an HTML string w/ a denominator, or 'unknown'). 5615 5616 As with `grade_string`, config values are pulled from the given local 5617 and/or task info if provided. 5618 """ 5619 timeliness_points = fallback_config_value( 5620 "TIMELINESS_POINTS", 5621 local_info or {}, 5622 task_info or {}, 5623 app.config, 5624 DEFAULT_CONFIG 5625 ) 5626 if timeliness_points is NotFound: 5627 timeliness_points = 0 5628 5629 round_to = fallback_config_value( 5630 "ROUND_SCORES_TO", 5631 local_info or {}, 5632 task_info or {}, 5633 app.config, 5634 DEFAULT_CONFIG 5635 ) 5636 if round_to is NotFound: 5637 round_to = 1 5638 5639 if grade_value is None or not isinstance(grade_value, (int, float)): 5640 return "unknown" 5641 else: 5642 rounded = round(grade_value, round_to) 5643 rdenom = round(timeliness_points, round_to) 5644 if int(rounded) == rounded: 5645 rounded = int(rounded) 5646 if int(rdenom) == rdenom: 5647 rdenom = int(rdenom) 5648 return "{} / {}".format(rounded, rdenom)
Turns a timeliness points value (None, or a number) into a timeliness grade string (an HTML string w/ a denominator, or 'unknown').
As with grade_string
, config values are pulled from the given local
and/or task info if provided.
5651@app.template_filter() 5652def shorter_grade(grade_string): 5653 """ 5654 Shortens a grade string. 5655 """ 5656 divis = " / " 5657 if divis in grade_string: 5658 return grade_string[:grade_string.index(divis)] 5659 elif grade_string == "unknown": 5660 return "?" 5661 else: 5662 return "!"
Shortens a grade string.
5665@app.template_filter() 5666def grade_category(grade_value, task_info=None, local_info=None): 5667 """ 5668 Categorizes a grade value (0-100 or None). 5669 5670 As with `grade_string`, config values are pulled from the given local 5671 and/or task info if provided. 5672 """ 5673 low_threshold = fallback_config_value( 5674 ["GRADE_THRESHOLDS", "low"], 5675 local_info or {}, 5676 task_info or {}, 5677 app.config, 5678 DEFAULT_CONFIG 5679 ) 5680 if low_threshold is NotFound: 5681 low_threshold = 75 5682 5683 mid_threshold = fallback_config_value( 5684 ["GRADE_THRESHOLDS", "mid"], 5685 local_info or {}, 5686 task_info or {}, 5687 app.config, 5688 DEFAULT_CONFIG 5689 ) 5690 if mid_threshold is NotFound: 5691 mid_threshold = 90 5692 5693 if grade_value is None: 5694 return "missing" 5695 elif grade_value < low_threshold: 5696 return "low" 5697 elif grade_value < mid_threshold: 5698 return "mid" 5699 else: 5700 return "high"
Categorizes a grade value (0-100 or None).
As with grade_string
, config values are pulled from the given local
and/or task info if provided.
5703@app.template_filter() 5704def timespent(time_spent): 5705 """ 5706 Handles numerical-or-string-or-None time spent values. 5707 """ 5708 if isinstance(time_spent, (int, float)): 5709 if time_spent == 0: 5710 return '-' 5711 elif int(time_spent) == time_spent: 5712 return "{}h".format(int(time_spent)) 5713 else: 5714 return "{}h".format(round(time_spent, 2)) 5715 elif isinstance(time_spent, str): 5716 return time_spent 5717 else: 5718 return "?"
Handles numerical-or-string-or-None time spent values.
5721@app.template_filter() 5722def initials(full_section_title): 5723 """ 5724 Reduces a full section title potentially including time information 5725 and the word 'Prof.' or 'Professor' to just first initials. 5726 5727 Returns '?' if the input value is `None`. 5728 """ 5729 if full_section_title is None: 5730 return '?' 5731 words = full_section_title.split() 5732 if len(words) > 1: 5733 if words[0] in ('Prof', 'Prof.', 'Professor'): 5734 words.pop(0) 5735 return words[0][0] + words[1][0] 5736 else: 5737 return full_section_title
Reduces a full section title potentially including time information and the word 'Prof.' or 'Professor' to just first initials.
Returns '?' if the input value is None
.
5740@app.template_filter() 5741def pronoun(pronouns): 5742 """ 5743 Reduces pronoun info to a single pronoun. 5744 """ 5745 test = '/'.join(re.split(r"[^A-Za-z]+", pronouns.lower())) 5746 if test in ( 5747 "she", 5748 "she/her", 5749 "she/hers", 5750 "she/her/hers" 5751 ): 5752 return "she" 5753 elif test in ( 5754 "he", 5755 "he/him", 5756 "he/his", 5757 "he/him/his" 5758 ): 5759 return "he" 5760 elif test in ( 5761 "they", 5762 "they/them", 5763 "them/them/their" 5764 ): 5765 return "they" 5766 else: 5767 return pronouns
Reduces pronoun info to a single pronoun.
5789@app.template_filter() 5790def escapejs(value): 5791 """ 5792 Modified from: 5793 5794 https://stackoverflow.com/questions/12339806/escape-strings-for-javascript-using-jinja2 5795 5796 Escapes string values so that they can appear inside of quotes in a 5797 <script> tag and they won't end the quotes or cause any other 5798 trouble. 5799 """ 5800 retval = [] 5801 for char in value: 5802 if char in _JS_ESCAPES: 5803 retval.append(r'\u{:04X}'.format(ord(char))) 5804 else: 5805 retval.append(char) 5806 5807 return jinja2.Markup(u"".join(retval))
Modified from:
https://stackoverflow.com/questions/12339806/escape-strings-for-javascript-using-jinja2
Escapes string values so that they can appear inside of quotes in a <script> tag and they won't end the quotes or cause any other trouble.