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 "{}&nbsp;/&nbsp;{}".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 "{}&nbsp;/&nbsp;{}".format(rounded, rdenom)
5648
5649
5650@app.template_filter()
5651def shorter_grade(grade_string):
5652    """
5653    Shortens a grade string.
5654    """
5655    divis = "&nbsp;/&nbsp;"
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    &lt;script&gt; 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
def ensure_directory(target):
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.

class InitApp(flask.app.Flask):
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.

InitApp(*args, **kwargs)
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__.

def init(self, action):
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):
    ...
def setup(self):
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).

def run(self, *args, **kwargs):
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
class NotFound:
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.

NotFound()
def fallback_config_value(key_or_keys, *look_in):
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.

def usual_config_value( key_or_keys, taskinfo, task=None, project=None, exercise=None, default=<class 'potluck_server.app.NotFound'>):
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.

@app.init
def setup_jinja_loader(app):
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.

@app.init
def enable_CAS(app):
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.

@app.init
def setup_potluck_reporting(app):
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).

@app.init
def create_secret_key(app):
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.

@app.init
def initialize_storage_module(app):
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.

@app.init
def ensure_required_folders(app):
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?

@app.init
def enable_talisman(app):
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.

@app.init
def setup_seasurf(app):
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.

def augment_arguments(route_function):
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.

def goback(course, semester):
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.

@app.route('/')
def route_index():
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.

@app.route('/dashboard')
@flask_cas.login_required
def route_default_dash():
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.

def route_dash(course, semester):

Displays dashboard w/ links for submitting each project/task & summary information of task grades. Also includes info on submitted exercises.

def route_feedback(course, semester, target_user, phase, prid, taskid):

Displays feedback on a particular task of a particular problem set, for either the 'initial' or 'revision' phase.

def route_submit(course, semester, prid, taskid):

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.

def which_exe(target, cwd='.'):
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.

def launch_potluck( course, semester, username, taskid, target_file, logfile, reportfile, wait=False):
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.

def route_extension(course, semester, prid):

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.

def route_set_extensions(course, semester, target_user, prid):

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!!!

def route_manage_extensions(course, semester, target_user):

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.

def route_set_evaluation(course, semester, target_user, phase, prid, taskid):

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.

def route_evaluate(course, semester, target_user, phase, prid, taskid):

Displays student feedback and also includes a form at the top for adding a custom note and/or overriding the grade.

def route_solution(course, semester, prid, taskid):

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.

@app.route('/<course>/<semester>/starter/<taskid>.zip', methods=['GET'])
@flask_cas.login_required
def route_starter_zip(course, semester, taskid):
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.

def route_full_gradesheet(course, semester):

Visible by admins only, this route displays an overview of the status of every student on the roster, for ALL exercises and projects.

def route_gradesheet(course, semester, prid):

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.

@app.route('/<course>/<semester>/deliver', methods=['GET', 'POST'])
def route_deliver(course, semester):
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.

def route_exercise_override(course, semester, target_user, eid):

Accessible by admins only, this route is the form target for the exercise and/or exercise group override controls.

def route_ex_gradesheet(course, semester, group):

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.

def error_response(course, semester, username, cause):
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.

def get_pr_obj(task_info, prid):
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.

def get_task_obj(task_info, pr_obj, taskid, redirect=<class 'Exception'>):
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.

def check_user_privileges(admin_info, username):
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.

def set_pause_time(admin_info, task_info, username, masquerade_as=None):
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.

def amend_task_info(course, semester, task_info, username):
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.

def amend_project_and_tasks(course, semester, task_info, project_obj, username):
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.

def amend_project(course, semester, task_info, project_obj, username):
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.

def amend_task(course, semester, task_info, prid, task, username, phase):
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)
def exercise_credit(exercise_info, outcomes):
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.

def get_exercise_deadline(course, semester, username, task_info, egroup):
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.

def fetch_all_best_outcomes( course, semester, username, task_info, only_groups=None, only_exercises=None):
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.

def amend_exercises(course, semester, task_info, outcomes, username):
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:

  1. If all of the exercises in the group are complete, it counts as "perfect".
  2. 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".
  3. If at least EXERCISE_GROUP_PARTIAL_THRESHOLD (default 2/5) but less than the EXERCISE_GROUP_THRESHOLD fraction of the exercises are complete, and the phase is "due" then the status will be "partial".
  4. 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".
  5. 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).
  6. 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.

def set_concept_statuses(concepts, task_info, outcomes):
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.
def all_parents(concepts, concept_path):
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.

def note_exercise_status(concepts, eid, exercise_status, concept_path):
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.

def attach_outcome(concepts, outcome_info, concept_path):
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.

def augment_concepts(concepts):
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 value None.
  • '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.

def lookup_concept(concepts, concept_names):
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.

def percentile(dataset, pct):
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.

def get_feedback_pr_and_task(task_info, course, semester, user, phase, prid, taskid):
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.

def get_evaluation_info(course, semester, target_user, phase, prid, taskid):
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.

def project_status_now(username, task_info, project_obj, extensions=(False, False)):
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.

def get_submission_filename(course, semester, task_info, username, phase, prid, taskid):
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?

@app.template_filter()
def sorted(*args, **kwargs):
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...

@app.template_filter()
def fuzzy_time(seconds):
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.

@app.template_filter()
def timestamp(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.

@app.template_filter()
def seconds(timedelta):
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.

@app.template_filter()
def integer(value):
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.

@app.template_filter()
def a_an(h):
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).

@app.template_filter()
def project_combined_grade(project, task_info=None):
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.

@app.template_filter()
def uses_pools(project):
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.

@app.template_filter()
def task_pool(task):
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.

@app.template_filter()
def project_pools(project):
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.

@app.template_filter()
def pool_colspan(project, taskid):
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.

@app.template_filter()
def task_combined_grade(task, task_info=None):
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.

@app.template_filter()
def task_timeliness_points(task, task_info=None):
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!

@app.template_filter()
def ex_combined_grade(egroup, task_info=None):
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.

@app.template_filter()
def grade_string(grade_value, task_info=None, local_info=None):
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 "{}&nbsp;/&nbsp;{}".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.

@app.template_filter()
def timeliness_string(grade_value, task_info=None, local_info=None):
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 "{}&nbsp;/&nbsp;{}".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.

@app.template_filter()
def shorter_grade(grade_string):
5651@app.template_filter()
5652def shorter_grade(grade_string):
5653    """
5654    Shortens a grade string.
5655    """
5656    divis = "&nbsp;/&nbsp;"
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.

@app.template_filter()
def grade_category(grade_value, task_info=None, local_info=None):
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.

@app.template_filter()
def timespent(time_spent):
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.

@app.template_filter()
def initials(full_section_title):
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.

@app.template_filter()
def pronoun(pronouns):
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.

@app.template_filter()
def escapejs(value):
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    &lt;script&gt; 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.