potluck_server.storage

Successor to sync; this is advanced synchronization & caching for Flask apps, using Redis.

storage.py

   1"""
   2Successor to sync; this is advanced synchronization & caching for Flask
   3apps, using Redis.
   4
   5storage.py
   6"""
   7
   8# Attempts at 2/3 dual compatibility:
   9from __future__ import print_function
  10
  11__version__ = "0.3.2"
  12
  13import sys, os, shutil, subprocess, threading, copy
  14import time, datetime
  15import base64, csv
  16import shlex
  17
  18from flask import json
  19
  20import flask, redis, bs4, werkzeug
  21
  22import potluck.time_utils, potluck.html_tools, potluck.render
  23
  24
  25# Python 2/3 dual compatibility
  26if sys.version_info[0] < 3:
  27    reload(sys) # noqa F821
  28    sys.setdefaultencoding('utf-8')
  29    import socket
  30    ConnectionRefusedError = socket.error
  31    IOError_or_FileNotFoundError = IOError
  32    OSError_or_FileNotFoundError = OSError
  33else:
  34    IOError_or_FileNotFoundError = FileNotFoundError
  35    OSError_or_FileNotFoundError = FileNotFoundError
  36
  37
  38# Look for safe_join both in flask and in werkzeug...
  39if hasattr(flask, "safe_join"):
  40    safe_join = flask.safe_join
  41elif hasattr(werkzeug.utils, "safe_join"):
  42    safe_join = werkzeug.utils.safe_join
  43else:
  44    print(
  45        "Warning: safe_join was not found in either flask OR"
  46        " werkzeug.utils; using an unsafe function instead."
  47    )
  48    safe_join = lambda *args: os.path.join(*args)
  49
  50#-----------#
  51# Constants #
  52#-----------#
  53
  54SCHEMA_VERSION = "1"
  55"""
  56The version for the schema used to organize information under keys in
  57Redis. If this changes, all Redis keys will change.
  58"""
  59
  60
  61#-----------#
  62# Utilities #
  63#-----------#
  64
  65def ensure_directory(target):
  66    """
  67    makedirs 2/3 shim.
  68    """
  69    if sys.version_info[0] < 3:
  70        try:
  71            os.makedirs(target)
  72        except OSError:
  73            pass
  74    else:
  75        os.makedirs(target, exist_ok=True)
  76
  77
  78#--------------------#
  79# Filename functions #
  80#--------------------#
  81
  82def unused_filename(target):
  83    """
  84    Checks whether the target already exists, and if it does, appends _N
  85    before the file extension, where N is the smallest positive integer
  86    such that the returned filename is not the name of an existing file.
  87    If the target does not exists, returns it.
  88    """
  89    n = 1
  90    backup = target
  91    base, ext = os.path.splitext(target)
  92    while os.path.exists(backup):
  93        backup = base + "_" + str(n) + ext
  94        n += 1
  95
  96    return backup
  97
  98
  99def make_way_for(target):
 100    """
 101    Given that we're about to overwrite the given file, this function
 102    moves any existing file to a backup first, numbering backups starting
 103    with _1. The most-recent backup will have the largest backup number.
 104
 105    After calling this function, the given target file will not exist,
 106    and so new material can be safely written there.
 107    """
 108    backup = unused_filename(target)
 109    if backup != target:
 110        shutil.move(target, backup)
 111
 112
 113def evaluation_directory(course, semester):
 114    """
 115    The evaluation directory for a particular class/semester.
 116    """
 117    return os.path.join(
 118        _EVAL_BASE,
 119        course,
 120        semester
 121    )
 122
 123
 124def logs_folder(course, semester, username):
 125    """
 126    The logs folder for a class/semester/user.
 127    """
 128    return safe_join(
 129        evaluation_directory(course, semester),
 130        "logs",
 131        username
 132    )
 133
 134
 135def reports_folder(course, semester, username):
 136    """
 137    The reports folder for a class/semester/user.
 138    """
 139    return safe_join(
 140        evaluation_directory(course, semester),
 141        "reports",
 142        username
 143    )
 144
 145
 146def submissions_folder(course, semester):
 147    """
 148    The submissions folder for a class/semester.
 149    """
 150    return os.path.join(
 151        evaluation_directory(course, semester),
 152        "submissions"
 153    )
 154
 155
 156def admin_info_file(course, semester):
 157    """
 158    The admin info file for a class/semester.
 159    """
 160    return os.path.join(
 161        evaluation_directory(course, semester),
 162        _CONFIG["ADMIN_INFO_FILE"]
 163    )
 164
 165
 166def task_info_file(course, semester):
 167    """
 168    The task info file for a class/semester.
 169    """
 170    return os.path.join(
 171        evaluation_directory(course, semester),
 172        _CONFIG.get("TASK_INFO_FILE", "tasks.json")
 173    )
 174
 175
 176def concepts_file(course, semester):
 177    """
 178    The concepts file for a class/semester.
 179    """
 180    return os.path.join(
 181        evaluation_directory(course, semester),
 182        _CONFIG.get("CONCEPTS_FILE", "pl_concepts.json")
 183    )
 184
 185
 186def roster_file(course, semester):
 187    """
 188    The roster file for a class/semester.
 189    """
 190    return os.path.join(
 191        evaluation_directory(course, semester),
 192        _CONFIG["ROSTER_FILE"]
 193    )
 194
 195
 196def student_info_file(course, semester):
 197    """
 198    The student info file for a class/semester.
 199    """
 200    return os.path.join(
 201        evaluation_directory(course, semester),
 202        _CONFIG["STUDENT_INFO_FILE"]
 203    )
 204
 205
 206#---------------------#
 207# Redis key functions #
 208#---------------------#
 209
 210def redis_key(suffix):
 211    """
 212    Given a key suffix, returns a full Redis key which includes
 213    "potluck:<version>" where version is the schema version (see
 214    `SCHEMA_VERSION`).
 215    """
 216    return "potluck:" + SCHEMA_VERSION + ":" + suffix
 217
 218
 219def redis_key_suffix(key):
 220    """
 221    Returns the part of a Redis key that wasn't added by the `redis_key`
 222    function.
 223    """
 224    return key[len(redis_key("")):]
 225
 226
 227def inflight_key(course, semester, username, project, task, phase):
 228    """
 229    The in-flight key for a class/semester/user/project/task/phase.
 230    """
 231    return redis_key(
 232        ':'.join(
 233            [
 234                course,
 235                semester,
 236                "inflight",
 237                username,
 238                project,
 239                task,
 240                phase
 241            ]
 242        )
 243    )
 244
 245
 246def extension_key(course, semester, username, project, phase):
 247    """
 248    The Redis key for the extension for a
 249    class/semester/user/project/phase.
 250    """
 251    return redis_key(
 252        ':'.join([course, semester, "ext", username, project, phase])
 253    )
 254
 255
 256def time_spent_key(course, semester, username, project, phase, task):
 257    """
 258    The Redis key for the time-spent info for a
 259    class/semester/user/project/phase/task.
 260    """
 261    return redis_key(
 262        ':'.join(
 263            [course, semester, "spent", username, project, phase, task]
 264        )
 265    )
 266
 267
 268def evaluation_key(course, semester, username, project, phase, task):
 269    """
 270    The Redis key for the custom evaluation info for a
 271    class/semester/user/project/phase/task.
 272    """
 273    return redis_key(
 274        ':'.join(
 275            [course, semester, "eval", username, project, phase, task]
 276        )
 277    )
 278
 279
 280def egroup_override_key(course, semester, username, egroup):
 281    """
 282    The Redis key for the grade override info for a
 283    class/semester/user/egroup
 284    """
 285    return redis_key(
 286        ':'.join(
 287            [course, semester, "egover", username, egroup]
 288        )
 289    )
 290
 291
 292def old_exercise_key(course, semester, username, exercise):
 293    """
 294    Old-format exercises key.
 295    """
 296    return redis_key(
 297        ':'.join(
 298            [course, semester, "outcomes", username, exercise]
 299        )
 300    )
 301
 302
 303def exercise_key(course, semester, username, exercise, category):
 304    """
 305    The Redis key for the outcomes-list-history for a particular
 306    exercise, submitted by a user for a particular course/semester.
 307    These are further split by category: no-credit, partial-credit, and
 308    full-credit exercises are stored in different history lists.
 309    """
 310    return redis_key(
 311        ':'.join(
 312            [course, semester, "outcomes", username, exercise, category]
 313        )
 314    )
 315
 316
 317#----------------#
 318# Roster loading #
 319#----------------#
 320
 321def get_variable_field_value(titles, row, name_options):
 322    """
 323    Extracts info from a CSV row that might be found in different columns
 324    (or combinations of columns).
 325
 326    Needs a list of column titles (lowercased strings), plus a name
 327    options list, and the row to extract from. Each item in the name
 328    options list is either a string (column name in lowercase), or a
 329    tuple containing one or more strings followed by a function to be
 330    called with the values of those columns as arguments whose result
 331    will be used as the value.
 332
 333    Returns the value from the row as extracted by the extraction
 334    function, or simply looked up in the case of a string entry. Tries
 335    options from the first to the last and returns the value from the
 336    first one that worked.
 337    """
 338    obj = {}
 339    val = obj
 340    for opt in name_options:
 341        if isinstance(opt, str):
 342            try:
 343                val = row[titles.index(opt)]
 344            except ValueError:
 345                continue
 346        else:
 347            try:
 348                args = [row[titles.index(name)] for name in opt[:-1]]
 349            except ValueError:
 350                continue
 351            val = opt[-1](*args)
 352
 353        if val is not obj:
 354            return val
 355
 356    optitems = [
 357        "'" + opt + "'"
 358            if isinstance(opt, str)
 359            else ' & '.join(["'" + o + "'" for o in opt[:-1]])
 360        for opt in name_options
 361    ]
 362    if len(optitems) == 1:
 363        optsummary = optitems[0]
 364    elif len(optitems) == 2:
 365        optsummary = optitems[0] + " or " + optitems[1]
 366    else:
 367        optsummary = ', '.join(optitems[:-1]) + ", or " + optitems[-1]
 368    raise ValueError(
 369        "Roster does not have column(s) {}. Columns are:\n  {}".format(
 370            optsummary,
 371            '\n  '.join(titles)
 372        )
 373    )
 374
 375
 376def load_roster_from_stream(iterable_of_strings):
 377    """
 378    Implements the roster-loading logic given an iterable of strings,
 379    like an open file or a list of strings. See `AsRoster`.
 380
 381    Each entry in the dictionary it returns has the following keys:
 382
 383    - 'username': The student's username
 384    - 'fullname': The student's full name (as it appears in the roster
 385        file, so not always what they want to go by).
 386    - 'sortname': A version of their name with 'last' name first that is
 387        used to sort students.
 388    - 'course_section': Which section the student is in.
 389    """
 390    reader = csv.reader(iterable_of_strings)
 391
 392    students = {}
 393    # [2018/09/16, lyn] Change to handle roster with titles
 394    # [2019/09/13, Peter] Change to use standard Registrar roster columns
 395    # by default
 396    titles = next(reader) # Read first title line of roster
 397    titles = [x.lower() for x in titles] # convert columns to lower-case
 398
 399    if "sort name" in titles:
 400        sortnameIndex = titles.index('sort name')
 401    elif "sortname" in titles:
 402        sortnameIndex = titles.index('sortname')
 403    else:
 404        sortnameIndex = None
 405
 406    for row in reader:
 407        username = get_variable_field_value(
 408            titles,
 409            row,
 410            [
 411                ('email', lambda e: e.split('@')[0]),
 412                'username'
 413            ]
 414        )
 415        if 0 < len(username):
 416            name = get_variable_field_value(
 417                titles,
 418                row,
 419                [
 420                    'name', 'student name',
 421                    ('first', 'last', lambda fi, la: fi + ' ' + la),
 422                    'sort name'
 423                ]
 424            )
 425            section = get_variable_field_value(
 426                titles,
 427                row,
 428                [
 429                    'section',
 430                    'lecture section',
 431                    'lec',
 432                    'lec sec',
 433                    'lec section',
 434                    'course_title',
 435                ]
 436            )
 437            namebits = name.split()
 438            if sortnameIndex is not None:
 439                sort_by = row[sortnameIndex]
 440            else:
 441                sort_by = ' '.join(
 442                    [section, namebits[-1]]
 443                    + namebits[:-1]
 444                )
 445            students[username] = {
 446                'username': username,
 447                'fullname': name,
 448                'sortname': sort_by,
 449                'course_section': section
 450            }
 451        pass
 452    pass
 453    return students
 454
 455
 456#-------------------------#
 457# Info fetching functions #
 458#-------------------------#
 459
 460def get_task_info(course, semester):
 461    """
 462    Loads the task info from the JSON file (or returns a cached version
 463    if the file hasn't been modified since we last loaded it). Needs the
 464    course and semester to load info for.
 465
 466    Returns None if the file doesn't exist or can't be parsed.
 467
 468    Pset and task URLs are added to the information loaded.
 469    """
 470    filename = task_info_file(course, semester)
 471    try:
 472        result = load_or_get_cached(
 473            filename,
 474            assume_fresh=_CONFIG.get("ASSUME_FRESH", 1)
 475        )
 476    except Exception:
 477        flask.flash("Failed to read task info file!")
 478        tb = potluck.html_tools.string_traceback()
 479        print(
 480            "Failed to read task info file:\n" + tb,
 481            file=sys.stderr
 482        )
 483        result = None
 484
 485    if result is None:
 486        return None
 487
 488    # Augment task info
 489    prfmt = result.get(
 490        "project_url_format",
 491        _CONFIG.get("DEFAULT_PROJECT_URL_FORMAT", "#")
 492    )
 493    taskfmt = result.get(
 494        "task_url_format",
 495        _CONFIG.get("DEFAULT_TASK_URL_FORMAT", "#")
 496    )
 497    for project in result.get("projects", result.get("psets")):
 498        project["url"] = prfmt.format(
 499            semester=semester,
 500            project=project["id"]
 501        )
 502        for task in project["tasks"]:
 503            task["url"] = taskfmt.format(
 504                semester=semester,
 505                project=project["id"],
 506                task=task["id"]
 507            )
 508            # Graft static task info into project task entry
 509            task.update(result["tasks"][task["id"]])
 510
 511    # Augment exercise info if it's present
 512    exfmt = result.get(
 513        "exercise_url_format",
 514        _CONFIG.get("DEFAULT_EXERCISE_URL_FORMAT", "#")
 515    )
 516    if 'exercises' in result:
 517        for egroup in result["exercises"]:
 518            if 'url' not in egroup:
 519                egroup["url"] = exfmt.format(
 520                    semester=semester,
 521                    group=egroup["group"]
 522                )
 523
 524    return result
 525
 526
 527def get_concepts(course, semester):
 528    """
 529    Loads concepts from the JSON file (or returns a cached version if the
 530    file hasn't been modified since we last loaded it). Needs the course
 531    and semester to load info for.
 532
 533    Returns None if the file doesn't exist or can't be parsed.
 534    """
 535    filename = concepts_file(course, semester)
 536    try:
 537        return load_or_get_cached(
 538            filename,
 539            assume_fresh=_CONFIG.get("ASSUME_FRESH", 1)
 540        )
 541    except Exception:
 542        flask.flash("Failed to read concepts file!")
 543        tb = potluck.html_tools.string_traceback()
 544        print(
 545            "Failed to read concepts file:\n" + tb,
 546            file=sys.stderr
 547        )
 548        return None
 549
 550
 551def get_admin_info(course, semester):
 552    """
 553    Reads the admin info file to get information about which users are
 554    administrators and various other settings.
 555    """
 556    filename = admin_info_file(course, semester)
 557    try:
 558        result = load_or_get_cached(
 559            filename,
 560            assume_fresh=_CONFIG.get("ASSUME_FRESH", 1)
 561        )
 562    except Exception:
 563        flask.flash("Failed to read admin info file '{}'!".format(filename))
 564        tb = potluck.html_tools.string_traceback()
 565        print(
 566            "Failed to read admin info file:\n" + tb,
 567            file=sys.stderr
 568        )
 569        result = None
 570
 571    return result # might be None
 572
 573
 574def get_roster(course, semester):
 575    """
 576    Loads and returns the roster file. Returns None if the file is
 577    missing. Returns a dictionary where usernames are keys and values are
 578    student info (see `AsRoster`).
 579    """
 580    return load_or_get_cached(
 581        roster_file(course, semester),
 582        view=AsRoster,
 583        missing=None,
 584        assume_fresh=_CONFIG.get("ASSUME_FRESH", 1)
 585    )
 586
 587
 588def get_student_info(course, semester):
 589    """
 590    Loads and returns the student info file. Returns None if the file is
 591    missing.
 592    """
 593    return load_or_get_cached(
 594        student_info_file(course, semester),
 595        view=AsStudentInfo,
 596        missing=None,
 597        assume_fresh=_CONFIG.get("ASSUME_FRESH", 1)
 598    )
 599
 600
 601def get_extension(course, semester, username, project, phase):
 602    """
 603    Gets the extension value (as an integer number in hours) for a user
 604    on a given phase of a given project. Returns 0 if there is no
 605    extension info for that user. Returns None if there's an error
 606    reading the value.
 607    """
 608    key = extension_key(course, semester, username, project, phase)
 609    try:
 610        result = _REDIS.get(key)
 611    except Exception:
 612        flask.flash(
 613            "Failed to read extension info at '{}'!".format(key)
 614        )
 615        tb = potluck.html_tools.string_traceback()
 616        print(
 617            "Failed to read extension info at '{}':\n{}".format(
 618                key,
 619                tb
 620            ),
 621            file=sys.stderr
 622        )
 623        return None
 624
 625    if result is None:
 626        result = 0
 627    else:
 628        result = int(result)
 629
 630    return result
 631
 632
 633def set_extension(
 634    course,
 635    semester,
 636    username,
 637    prid,
 638    phase,
 639    duration=True,
 640    only_from=None
 641):
 642    """
 643    Sets an extension value for the given user on the given phase of the
 644    given project (in the given course/semester). May be an integer
 645    number of hours, or just True (the default) for the standard
 646    extension (whatever is listed in tasks.json). Set to False to remove
 647    any previously granted extension.
 648
 649    If only_from is provided, the operation will fail when the extension
 650    value being updated isn't set to that value (may be a number of
 651    hours, or True for the standard extension, or False for unset). In
 652    that case, this function will return False if it fails. Set
 653    only_from to None to unconditionally update the extension.
 654    """
 655    key = extension_key(course, semester, username, prid, phase)
 656    task_info = get_task_info(course, semester)
 657    ext_hours = task_info.get("extension_hours", 24)
 658
 659    if duration is True:
 660        duration = ext_hours
 661    elif duration is False:
 662        duration = 0
 663    elif not isinstance(duration, (int, bool)):
 664        raise ValueError(
 665            (
 666                "Extension duration must be an integer number of hours,"
 667                " or a boolean (got {})."
 668            ).format(repr(duration))
 669        )
 670
 671    if only_from is True:
 672        only_from = ext_hours
 673    elif (
 674        only_from not in (False, None)
 675    and not isinstance(only_from, int)
 676    ):
 677        raise ValueError(
 678            (
 679                "Only-from must be None, a boolean, or an integer (got"
 680                " {})."
 681            ).format(repr(only_from))
 682        )
 683
 684    with _REDIS.pipeline() as pipe:
 685        # Make sure we back off if there's a WatchError
 686        try:
 687            pipe.watch(key)
 688            # Check current value
 689            current = _REDIS.get(key)
 690            if current is not None:
 691                current = int(current) # convert from string
 692
 693            if duration == current:
 694                # No need to update!
 695                return True
 696
 697            if only_from is not None and (
 698                (only_from is False and current not in (None, 0))
 699             or (only_from is not False and current != only_from)
 700            ):
 701                # Abort operation because of pre-op change
 702                flask.flash(
 703                    (
 704                        "Failed to write extension info at '{}' (slow"
 705                        " change)!"
 706                    ).format(key)
 707                )
 708                return False
 709
 710            # Go ahead and update the value
 711            pipe.multi()
 712            pipe.set(key, str(duration))
 713            pipe.execute()
 714        except redis.exceptions.WatchError:
 715            # Update didn't go through
 716            flask.flash(
 717                (
 718                    "Failed to write extension info at '{}' (fast"
 719                    " change)!"
 720                ).format(key)
 721            )
 722            return False
 723        except Exception:
 724            # Some other issue
 725            flask.flash(
 726                (
 727                    "Failed to write extension info at '{}' (unknown)!"
 728                ).format(key)
 729            )
 730            tb = potluck.html_tools.string_traceback()
 731            print(
 732                "Failed to write extension info at '{}':\n{}".format(
 733                    key,
 734                    tb
 735                ),
 736                file=sys.stderr
 737            )
 738            return False
 739
 740    return True
 741
 742
 743def get_inflight(
 744    course,
 745    semester,
 746    username,
 747    phase,
 748    prid,
 749    taskid
 750):
 751    """
 752    Returns a quadruple containing the timestamp at which processing for
 753    the given user/phase/project/task was started, the filename of the log
 754    file for that evaluation run, the filename of the report file that
 755    will be generated when it's done, and a string indicating the status
 756    of the run. Reads that log file to check whether the process has
 757    completed, and updates in-flight state accordingly. Returns (None,
 758    None, None, None) if no attempts to grade the given task have been
 759    made yet.
 760
 761    The status string will be one of:
 762
 763    - "initial" - evaluation hasn't started yet.
 764    - "in_progress" - evaluation is running.
 765    - "error" - evaluation noted an error in the log.
 766    - "expired" - We didn't hear back from evaluation, but it's been so
 767         long that we've given up hope.
 768    - "completed" - evaluation finished.
 769
 770    When status is "error", "expired", or "completed", it's appropriate
 771    to initiate a new evaluation run for that file, but in other cases,
 772    the existing run should be allowed to terminate first.
 773
 774    In rare cases, when an exception is encountered trying to read the
 775    file even after a second attempt, the timestamp will be set to
 776    "error" with status and filename values of None.
 777    """
 778    key = inflight_key(course, semester, username, phase, prid, taskid)
 779
 780    try:
 781        response = _REDIS.lrange(key, 0, -1)
 782    except Exception:
 783        flask.flash(
 784            "Failed to fetch in-flight info at '{}'!".format(key)
 785        )
 786        tb = potluck.html_tools.string_traceback()
 787        print(
 788            "Failed to fetch in-flight info at '{}':\n{}".format(
 789                key,
 790                tb
 791            ),
 792            file=sys.stderr
 793        )
 794        return ("error", None, None, None)
 795
 796    # If the key didn't exist
 797    if response is None or len(response) == 0:
 798        return (None, None, None, None)
 799
 800    # Unpack the response
 801    timestring, log_filename, report_filename, status = response
 802
 803    if status in ("error", "expired", "completed"):
 804        # No need to check the log text again
 805        return (timestring, log_filename, report_filename, status)
 806
 807    # Figure out what the new status should be...
 808    new_status = status
 809
 810    # Read the log file to see if evaluation has finished yet
 811    if os.path.isfile(log_filename):
 812        try:
 813            with open(log_filename, 'r') as fin:
 814                log_text = fin.read()
 815        except Exception:
 816            flask.flash("Failed to read evaluation log file!")
 817            tb = potluck.html_tools.string_traceback()
 818            print(
 819                "Failed to read evaluation log file:\n" + tb,
 820                file=sys.stderr
 821            )
 822            # Treat as a missing file
 823            log_text = ""
 824    else:
 825        # No log file
 826        log_text = ""
 827
 828    # If anything has been written to the log file, we're in progress...
 829    if status == "initial" and log_text != "":
 830        new_status = "in_progress"
 831
 832    # Check for an error
 833    if potluck.render.ERROR_MSG in log_text:
 834        new_status = "error"
 835
 836    # Check for completion message (ignored if there's an error)
 837    if (
 838        status in ("initial", "in_progress")
 839    and new_status != "error"
 840    and log_text.endswith(potluck.render.DONE_MSG + '\n')
 841    ):
 842        new_status = "completed"
 843
 844    # Check absolute timeout (only if we DIDN'T see a done message)
 845    if new_status not in ("error", "completed"):
 846        elapsed = (
 847            potluck.time_utils.now()
 848          - potluck.time_utils.time_from_timestring(timestring)
 849        )
 850        allowed = datetime.timedelta(
 851            seconds=_CONFIG["FINAL_EVAL_TIMEOUT"]
 852        )
 853        if elapsed > allowed:
 854            new_status = "expired"
 855
 856    # Now we've got our result
 857    result = (timestring, log_filename, report_filename, new_status)
 858
 859    # Write new status if it has changed
 860    if new_status != status:
 861        try:
 862            with _REDIS.pipeline() as pipe:
 863                pipe.delete(key) # clear the list
 864                pipe.rpush(key, *result) # add our new info
 865                pipe.execute()
 866        except Exception:
 867            flask.flash(
 868                (
 869                    "Error trying to update in-flight info at '{}'."
 870                ).format(key)
 871            )
 872            tb = potluck.html_tools.string_traceback()
 873            print(
 874                "Failed to update in-flight info at '{}':\n{}".format(
 875                    key,
 876                    tb
 877                ),
 878                file=sys.stderr
 879            )
 880            return ("error", None, None, None)
 881
 882    # Return our result
 883    return result
 884
 885
 886def put_inflight(course, semester, username, phase, prid, taskid):
 887    """
 888    Picks new log and report filenames for the given
 889    user/phase/project/task and returns a quad containing a string
 890    timestamp, the new log filename, the new report filename, and the
 891    status string "initial", while also writing that information into
 892    the inflight data for that user so that get_inflight will return it
 893    until evaluation is finished.
 894
 895    Returns (None, None, None, None) if there is already an in-flight
 896    log file for this user/project/task that has a status other than
 897    "error", "expired", or "completed".
 898
 899    Returns ("error", None, None, None) if it encounters a situation
 900    where the inflight key is changed during the update operation,
 901    presumably by another simultaneous call to put_inflight. This
 902    ensures that only one simultaneous call can succeed, and protects
 903    against race conditions on the log and report filenames.
 904    """
 905    # The Redis key for inflight info
 906    key = inflight_key(course, semester, username, phase, prid, taskid)
 907
 908    with _REDIS.pipeline() as pipe:
 909        try:
 910            pipe.watch(key)
 911            response = pipe.lrange(key, 0, -1)
 912            if response is not None and len(response) != 0:
 913                # key already exists, so we need to check status
 914                prev_ts, prev_log, prev_result, prev_status = response
 915                if prev_status in ("initial", "in_progress"):
 916                    # Another evaluation is in-flight; indicate that to
 917                    # our caller and refuse to re-initiate evaluation
 918                    return (None, None, None, None)
 919
 920            # Generate a timestamp for the log file
 921            timestamp = potluck.time_utils.timestring()
 922
 923            # Get unused log and report filenames
 924            istring = "{phase}-{prid}-{taskid}-{timestamp}".format(
 925                phase=phase,
 926                prid=prid,
 927                taskid=taskid,
 928                timestamp=timestamp
 929            )
 930
 931            # Note: unused_filename has a race condition if two
 932            # put_inflight calls occur simultaneously. However, due to
 933            # our use of watch, only one of the two calls can make it
 934            # out of this block without triggering a WatchError, meaning
 935            # that only the one that makes it out first will make use of
 936            # a potentially-conflicting filename. That said, *any* other
 937            # process which might create files with names like the log
 938            # and report filenames we select would be bad.
 939
 940            # Select an unused log filename
 941            log_folder = logs_folder(course, semester, username)
 942            ensure_directory(log_folder)
 943            logfile = unused_filename(
 944                safe_join(log_folder, istring + ".log")
 945            )
 946
 947            # Select an unused report filename
 948            report_folder = reports_folder(course, semester, username)
 949            ensure_directory(report_folder)
 950            reportfile = unused_filename(
 951                safe_join(report_folder, istring + ".json")
 952            )
 953
 954            # Gather the info into a tuple
 955            ifinfo = (
 956                timestamp,
 957                logfile,
 958                reportfile,
 959                "initial"
 960            )
 961
 962            # Rewrite the key
 963            pipe.multi()
 964            pipe.delete(key)
 965            pipe.rpush(key, *ifinfo)
 966            pipe.execute()
 967        except redis.exceptions.WatchError:
 968            flask.flash(
 969                (
 970                    "Unable to put task evaluation in-flight: key '{}'"
 971                    " was changed."
 972                ).format(key)
 973            )
 974            return ("error", None, None, None)
 975        except Exception:
 976            flask.flash(
 977                (
 978                    "Error trying to write in-flight info at '{}'."
 979                ).format(key)
 980            )
 981            tb = potluck.html_tools.string_traceback()
 982            print(
 983                "Failed to write in-flight info at '{}':\n{}".format(
 984                    key,
 985                    tb
 986                ),
 987                file=sys.stderr
 988            )
 989            return ("error", None, None, None)
 990
 991    # Return the timestamp, filenames, and status that we recorded
 992    return ifinfo
 993
 994
 995def fetch_time_spent(course, semester, username, phase, prid, taskid):
 996    """
 997    Returns a time-spent record for the given user/phase/project/task.
 998    It has the following keys:
 999
1000    - "phase": The phase (a string).
1001    - "prid": The project ID (a string).
1002    - "taskid": The task ID (a string).
1003    - "updated_at": A timestring (see `potluck.time_utils.timestring`)
1004        indicating when the information was last updated.
1005    - "time_spent": A floating-point number (as a string) or just a
1006        string describing the user's description of the time they spent
1007        on the task.
1008    - "prev_update": If present, indicates that the time_spent value
1009        came from a previous entry and was preserved when a newer entry
1010        would have been empty. Shows the time at which the previous
1011        entry was entered.
1012        TODO: preserve across multiple empty entries?
1013
1014    Returns None if there is no information for that user/project/task
1015    yet, or if an error is encountered while trying to access that
1016    information.
1017    """
1018    # Redis key to use
1019    key = time_spent_key(
1020        course,
1021        semester,
1022        username,
1023        prid,
1024        phase,
1025        taskid
1026    )
1027
1028    try:
1029        response = _REDIS.hmget(
1030            key,
1031            "updated_at",
1032            "time_spent",
1033            "prev_update"
1034        )
1035    except Exception:
1036        flask.flash("Error fetching time-spent info.")
1037        tb = potluck.html_tools.string_traceback()
1038        print(
1039            "Failed to fetch time spent info at '{}':\n{}".format(
1040                key,
1041                tb
1042            ),
1043            file=sys.stderr
1044        )
1045        return None
1046
1047    # Some kind of non-exception error during access, or key is missing
1048    if response is None or len(response) != 3 or response[0] is None:
1049        return None
1050
1051    try:
1052        spent = float(response[1])
1053    except ValueError:
1054        spent = response[1]
1055
1056    result = {
1057        "phase": phase,
1058        "prid": prid,
1059        "taskid": taskid,
1060        "updated_at": response[0],
1061        "time_spent": spent
1062    }
1063
1064    if response[2] is not None:
1065        result["prev_update"] = response[2]
1066
1067    return result
1068
1069
1070def record_time_spent(
1071    course,
1072    semester,
1073    username,
1074    phase,
1075    prid,
1076    taskid,
1077    time_spent
1078):
1079    """
1080    Inserts a time spent entry into the given user's time spent info.
1081
1082    If called multiple times, the last call will override the
1083    information set by any previous ones. If called multiple times
1084    simultaneously, one of the calls will overwrite the other, but it
1085    may not be able to pull the other call's info to replace a default
1086    value (which is fine...).
1087    """
1088    # Redis key to use
1089    key = time_spent_key(
1090        course,
1091        semester,
1092        username,
1093        prid,
1094        phase,
1095        taskid
1096    )
1097
1098    # Generate a timestamp for the info
1099    timestring = potluck.time_utils.timestring()
1100
1101    # Convert to a number if we can
1102    try:
1103        time_spent = float(time_spent)
1104    except Exception:
1105        pass
1106
1107    # Here's the info we store
1108    info = {
1109        "updated_at": timestring,
1110        "time_spent": time_spent
1111    }
1112
1113    # Check for old info if the new info is missing
1114    if time_spent == "":
1115        try:
1116            response = _REDIS.hmget(
1117                key,
1118                "updated_at",
1119                "time_spent",
1120                "prev_update"
1121            )
1122            if (
1123                response is None
1124             or len(response) != 2
1125            ):
1126                raise ValueError(
1127                    "Unable to retrieve previous data from time spent"
1128                    " info."
1129                )
1130            # check for missing key, or no previous info
1131            if response[0] is not None and response[1] != '':
1132                if response[2] is None:
1133                    prev = response[0]
1134                else:
1135                    prev = response[2]
1136
1137                info["prev_update"] = prev
1138                info["time_spent"] = response[1]
1139            # else leave info as-is
1140
1141        except Exception:
1142            flask.flash("Failed to fetch time spent info!")
1143            tb = potluck.html_tools.string_traceback()
1144            print(
1145                "Failed to fetch time spent info at '{}':\n{}".format(
1146                    key,
1147                    tb
1148                ),
1149                file=sys.stderr
1150            )
1151            # we'll keep going to update new info though
1152
1153    try:
1154        success = _REDIS.hmset(key, info)
1155        if success is not True:
1156            raise ValueError("Redis result indicated failure.")
1157    except Exception:
1158        flask.flash("Failed to write time-spent info!")
1159        tb = potluck.html_tools.string_traceback()
1160        print(
1161            "Failed to write time spent info at '{}':\n{}".format(
1162                key,
1163                tb
1164            ),
1165            file=sys.stderr
1166        )
1167
1168
1169def fetch_evaluation(course, semester, username, phase, prid, taskid):
1170    """
1171    Fetches the manual evaluation information for the given
1172    user/phase/project/task. The result will be a dictionary with the
1173    following keys:
1174
1175    - "phase": The phase (a string).
1176    - "prid": The project ID (a string).
1177    - "taskid": The task ID (a string).
1178    - "updated_at": A timestring (see `potluck.time_utils.timestring`)
1179        indicating when the information was last updated.
1180    - "notes": The markdown source string for custom notes.
1181    - "override": A numerical score that overrides the automatic
1182        evaluation. Will be an empty string if there is no override to
1183        apply.
1184    - "timeliness": A numerical score for timeliness points to override
1185        the automatic value. Will be an empty string if there is no
1186        override, which should always be the case for non-initial phases.
1187
1188    Returns None instead of a dictionary if there is no information for
1189    that user/project/task yet, or if an error is encountered while
1190    trying to access that information.
1191    """
1192    # Redis key to use
1193    key = evaluation_key(
1194        course,
1195        semester,
1196        username,
1197        prid,
1198        phase,
1199        taskid
1200    )
1201
1202    try:
1203        response = _REDIS.hmget(
1204            key,
1205            "updated_at",
1206            "notes",
1207            "override",
1208            "timeliness"
1209        )
1210    except Exception:
1211        flask.flash("Error fetching evaluation info.")
1212        tb = potluck.html_tools.string_traceback()
1213        print(
1214            "Failed to fetch evaluation info at '{}':\n{}".format(
1215                key,
1216                tb
1217            ),
1218            file=sys.stderr
1219        )
1220        return None
1221
1222    # Some kind of non-exception error during access, or key is missing
1223    if (
1224        response is None
1225     or len(response) != 4
1226     or response[0] is None
1227    ):
1228        return None
1229
1230    try:
1231        override = float(response[2])
1232    except (TypeError, ValueError):
1233        override = response[2] or ''
1234
1235    try:
1236        timeliness = float(response[3])
1237    except (TypeError, ValueError):
1238        timeliness = response[3] or ''
1239
1240    result = {
1241        "phase": phase,
1242        "prid": prid,
1243        "taskid": taskid,
1244        "updated_at": response[0],
1245        "notes": response[1],
1246        "override": override,
1247        "timeliness": timeliness,
1248    }
1249
1250    return result
1251
1252
1253def set_evaluation(
1254    course,
1255    semester,
1256    username,
1257    phase,
1258    prid,
1259    taskid,
1260    notes,
1261    override="",
1262    timeliness=""
1263):
1264    """
1265    Updates the custom evaluation info for a particular task submitted by
1266    a particular user for a certain phase of a specific project (in a
1267    course/semester).
1268
1269    Completely erases the previous custom evaluation info.
1270
1271    The notes argument must be a string, and will be treated as Markdown
1272    and converted to HTML when being displayed to the user. It will be
1273    displayed on a feedback page and it can thus link to rubric items or
1274    snippets by their IDs if you want to get fancy.
1275
1276    The override argument defaults to an empty string, which is how to
1277    indicate that no override should be applied. Otherwise, it should be
1278    a floating-point number or integer between 0 and 100; it will be
1279    stored as a float if convertible.
1280
1281    The timeliness argument works like the override argument, but
1282    overrides the timeliness score. It should only be set for the
1283    'initial' phase.
1284
1285    Returns True if it succeeds or False if it encounters some sort of
1286    error.
1287    """
1288    # Redis key to use
1289    key = evaluation_key(
1290        course,
1291        semester,
1292        username,
1293        prid,
1294        phase,
1295        taskid
1296    )
1297
1298    # Get task info for this course/semester so we can access per-course
1299    # config values...
1300    task_info = get_task_info(course, semester)
1301    relevant_task = task_info.get("tasks", {}).get(taskid, {})
1302    relevant_projects = [
1303        p
1304        for p in task_info.get("projects", task_info.get("psets", {}))
1305        if p["id"] == prid
1306    ]
1307    if len(relevant_projects) > 0:
1308        relevant_project = relevant_projects[0]
1309    else:
1310        relevant_project = {}
1311
1312    # Generate a timestamp for the info
1313    timestring = potluck.time_utils.timestring()
1314
1315    # Get SCORE_BASIS and TIMELINESS_POINTS values from
1316    # task/project/task_info/config
1317    score_basis = relevant_task.get(
1318        "SCORE_BASIS",
1319        relevant_project.get(
1320            "SCORE_BASIS",
1321            task_info.get(
1322                "SCORE_BASIS",
1323                _CONFIG.get("SCORE_BASIS", 100)
1324            )
1325        )
1326    )
1327    timeliness_basis = relevant_task.get(
1328        "TIMELINESS_POINTS",
1329        relevant_project.get(
1330            "TIMELINESS_POINTS",
1331            task_info.get(
1332                "TIMELINESS_POINTS",
1333                _CONFIG.get("TIMELINESS_POINTS", 10)
1334            )
1335        )
1336    )
1337
1338    # Convert to a number if we can
1339    if override != "":
1340        try:
1341            override = float(override)
1342            if 0 < override < 1 and score_basis >= 10:
1343                flask.flash(
1344                    (
1345                        "Warning: you entered '{}' as the grade"
1346                        " override, but scores should be specified out"
1347                        " of {}, not out of 1! The override has been"
1348                        " set as-given but you may want to update it."
1349                    ).format(override, score_basis)
1350                )
1351        except Exception:
1352            flask.flash(
1353                (
1354                    "Warning: you entered '{}' as the grade override,"
1355                    " but grade overrides should be numbers between 0"
1356                    " and {}. The override has been set as-given, but"
1357                    " you may want to update it."
1358                ).format(override, score_basis)
1359            )
1360
1361    # Convert to a number if we can
1362    if timeliness != "":
1363        try:
1364            timeliness = float(timeliness)
1365            if 0 < timeliness < 1 and timeliness_basis >= 5:
1366                flask.flash(
1367                    (
1368                        "Warning: you entered '{}' as the timeliness"
1369                        " override, but timeliness scores should be"
1370                        " specified out of {}, not out of 1! The"
1371                        " override has been set as-given but you may"
1372                        " want to update it."
1373                    ).format(timeliness, timeliness_basis)
1374                )
1375        except Exception:
1376            flask.flash(
1377                (
1378                    "Warning: you entered '{}' as the grade override,"
1379                    " but timeliness overrides should be numbers"
1380                    " between 0 and {}. The override has been set"
1381                    " as-given, but you may want to update it."
1382                ).format(override, timeliness_basis)
1383            )
1384
1385    # Here's the info we store
1386    info = {
1387        "updated_at": timestring,
1388        "notes": notes,
1389        "override": override,
1390        "timeliness": timeliness,
1391    }
1392
1393    try:
1394        success = _REDIS.hmset(key, info)
1395        if success is not True:
1396            raise ValueError("Redis result indicated failure.")
1397    except Exception:
1398        flask.flash("Failed to write evaluation info!")
1399        tb = potluck.html_tools.string_traceback()
1400        print(
1401            "Failed to write evaluation info at '{}':\n{}".format(
1402                key,
1403                tb
1404            ),
1405            file=sys.stderr
1406        )
1407        return False
1408
1409    return True
1410
1411
1412def get_egroup_override(
1413    course,
1414    semester,
1415    username,
1416    egroup
1417):
1418    """
1419    Returns the score override for a particular exercise group. The
1420    result is a dictionary with the following keys:
1421
1422    - "updated_at": A timestring indicating when the override was set.
1423    - "status": The status string specified by the override.
1424    - "note": A string specified by the person who set the override.
1425    - "override": The grade override, as a floating-point value based on
1426        the exercise group's SCORE_BASIS, or an empty string if there is
1427        no override.
1428
1429    In case of an error or when no override is present, the result will
1430    be `None`.
1431    """
1432    # Redis key to use
1433    key = egroup_override_key(
1434        course,
1435        semester,
1436        username,
1437        egroup
1438    )
1439
1440    try:
1441        response = _REDIS.hmget(
1442            key,
1443            "updated_at",
1444            "status",
1445            "note",
1446            "override"
1447        )
1448    except Exception:
1449        flask.flash(
1450            (
1451                "Error fetching exercise group override info for group"
1452                " '{}'."
1453            ).format(egroup)
1454        )
1455        tb = potluck.html_tools.string_traceback()
1456        print(
1457            "Failed to fetch exercise group override at '{}':\n{}".format(
1458                key,
1459                tb
1460            ),
1461            file=sys.stderr
1462        )
1463        return None
1464
1465    if response is None or len(response) != 4:
1466        return None
1467
1468    try:
1469        score = float(response[3])
1470    except (TypeError, ValueError):
1471        score = ''
1472
1473    return {
1474        "updated_at": response[0],
1475        "status": response[1],
1476        "note": response[2],
1477        "override": score
1478    }
1479
1480
1481def set_egroup_override(
1482    course,
1483    semester,
1484    username,
1485    egroup,
1486    override="",
1487    note="",
1488    status=""
1489):
1490    """
1491    Updates the exercise group score override for a particular exercise
1492    group submitted by a particular user for a specific exercise group
1493    (in a course/semester).
1494
1495    Completely erases the previous override info.
1496
1497    The `note` argument must be a string, and will be treated as Markdown
1498    and converted to HTML when being displayed to the user. It will be
1499    displayed on the student's dashboard in the expanded view for the
1500    exercise group.
1501
1502    The `override` argument defaults to an empty string, which is how to
1503    indicate that no override should be applied. Otherwise, it should be
1504    a floating-point number or integer between 0 and 1 (inclusive),
1505    indicating the fraction of full credit to award. It will be stored as
1506    a floating-point number if it's convertible to one. Note that this
1507    fraction is still subject to the `EXERCISE_GROUP_CREDIT_BUMP` logic
1508    in `app.ex_combined_grade`.
1509
1510    The `status` argument defaults to an empty string; in that case the
1511    status will not be changed. If not empty, it should be one of the
1512    strings "perfect," "complete," "partial," "incomplete," "pending,"
1513    or "unreleased."
1514
1515    Returns True if it succeeds or False if it encounters some sort of
1516    error.
1517    """
1518    # Redis key to use
1519    key = egroup_override_key(
1520        course,
1521        semester,
1522        username,
1523        egroup
1524    )
1525
1526    # Get task info for this course/semester so we can access per-course
1527    # config values...
1528    task_info = get_task_info(course, semester)
1529    all_egroups = task_info.get("exercises", [])
1530    this_eginfo = None
1531    for gr in all_egroups:
1532        if gr.get('group', '') == egroup:
1533            if this_eginfo is None:
1534                this_eginfo = gr
1535            else:
1536                flask.flash(
1537                    "Multiple exercise groups with group ID '{}'".format(
1538                        egroup
1539                    )
1540                )
1541                print(
1542                    "Multiple exercise groups with group ID '{}'".format(
1543                        egroup
1544                    ),
1545                    file=sys.stderr
1546                )
1547                # We keep using the first-specified group info
1548
1549    # No info for this egroup?
1550    if this_eginfo is None:
1551        flask.flash("No exercise group with group ID '{}'".format(egroup))
1552        print(
1553            "No exercise group with group ID '{}'".format(egroup),
1554            file=sys.stderr
1555        )
1556        return False
1557
1558    # Generate a timestamp for the info
1559    timestring = potluck.time_utils.timestring()
1560
1561    # Convert to a number if we can
1562    if override != "":
1563        try:
1564            override = float(override)
1565            if override > 1 or override < 0:
1566                flask.flash(
1567                    (
1568                        "Warning: you entered '{}' as the grade"
1569                        " override, but scores should be specified as"
1570                        " a fraction between 0.0 and 1.0. the override"
1571                        " has been set as-given but you may want to"
1572                        " update it."
1573                    ).format(override)
1574                )
1575        except Exception:
1576            flask.flash(
1577                (
1578                    "Warning: you entered '{}' as the grade override,"
1579                    " but grade overrides should be numbers."
1580                    " The override has been set as-given, but"
1581                    " you may want to update it."
1582                ).format(override)
1583            )
1584
1585    # Here's the info we store
1586    info = {
1587        "updated_at": timestring,
1588        "status": status,
1589        "note": note,
1590        "override": override
1591    }
1592
1593    try:
1594        success = _REDIS.hmset(key, info)
1595        if success is not True:
1596            raise ValueError("Redis result indicated failure.")
1597    except Exception:
1598        flask.flash("Failed to write evaluation info!")
1599        tb = potluck.html_tools.string_traceback()
1600        print(
1601            "Failed to write evaluation info at '{}':\n{}".format(
1602                key,
1603                tb
1604            ),
1605            file=sys.stderr
1606        )
1607        return False
1608
1609    return True
1610
1611
1612def fetch_old_outcomes(course, semester, username, exercise):
1613    """
1614    Fetches old outcomes for the given course/semester/username/exercise.
1615    """
1616    # Redis key to use
1617    key = old_exercise_key(course, semester, username, exercise)
1618
1619    try:
1620        exists = _REDIS.exists(key)
1621    except Exception:
1622        flask.flash("Error checking for outcomes info.")
1623        tb = potluck.html_tools.string_traceback()
1624        print(
1625            "Failed to check for outcomes info at '{}':\n{}".format(
1626                key,
1627                tb
1628            ),
1629            file=sys.stderr
1630        )
1631        return None
1632
1633    # Return None without making a fuss if the key just doesn't exist
1634    if not exists:
1635        return None
1636
1637    try:
1638        responseJSON = _REDIS.get(key)
1639        info = json.loads(responseJSON)
1640    except Exception:
1641        flask.flash("Error fetching or decoding outcomes info.")
1642        tb = potluck.html_tools.string_traceback()
1643        print(
1644            "Failed to fetch outcomes info at '{}':\n{}".format(
1645                key,
1646                tb
1647            ),
1648            file=sys.stderr
1649        )
1650        return None
1651
1652    return info
1653
1654
1655def fetch_outcomes(course, semester, username, exercise, category):
1656    """
1657    Fetches the outcomes-list information for the given
1658    user/course/semester/exercise/category. The 'category' should be one
1659    of the strings 'full', 'partial', or 'none'. The result will be a
1660    list of dictionaries each representing a single submission, in
1661    chronological order. Each will have the following keys:
1662
1663    - "submitted_at" - A time string (see
1664        `potluck.time_utils.timestring`) indicating when the list of
1665        outcomes was submitted.
1666    - "authors" - A list of usernames for participating authors.
1667    - "outcomes" - A list of 3-tuple outcomes, which contain a boolean
1668        for success/failure followed by tag and message strings (see
1669        `optimism.listOutcomesInSuite`)
1670    - "code": A list of filename, code pairs with any code blocks
1671        submitted along with the outcomes.
1672    - "status": A status string indicating the overall exercise status,
1673        determined by callers to `save_outcomes`.
1674    - "credit": A number indicating how much credit (0-1) was earned for
1675        this outcome. May also be `None` in some cases.
1676    - "group_credit": A credit number that determines how much credit is
1677        earned towards this exercise group. Will be 0 when "credit" is
1678        `None`. If the category is 'none', this will always be 0, if the
1679        category is 'partial' it will be greater than 0 and less than 1,
1680        and if the category is 'full', it will be 1. This number does NOT
1681        account for timeliness (yet; see `fetch_best_outcomes`).
1682
1683    Returns None instead of a list if there is no information for that
1684    user/exercise/category yet, or if an error is encountered while
1685    trying to access that information.
1686    """
1687    # Redis key to use
1688    key = exercise_key(course, semester, username, exercise, category)
1689
1690    try:
1691        exists = _REDIS.exists(key)
1692    except Exception:
1693        flask.flash("Error checking for outcomes info.")
1694        tb = potluck.html_tools.string_traceback()
1695        print(
1696            "Failed to check for outcomes info at '{}':\n{}".format(
1697                key,
1698                tb
1699            ),
1700            file=sys.stderr
1701        )
1702        return None
1703
1704    # Return None without making a fuss if the key just doesn't exist
1705    if not exists:
1706        return None
1707
1708    try:
1709        responseJSON = _REDIS.get(key)
1710        info = json.loads(responseJSON)
1711    except Exception:
1712        flask.flash("Error fetching or decoding outcomes info.")
1713        tb = potluck.html_tools.string_traceback()
1714        print(
1715            "Failed to fetch outcomes info at '{}':\n{}".format(
1716                key,
1717                tb
1718            ),
1719            file=sys.stderr
1720        )
1721        return None
1722
1723    return info
1724
1725
1726def update_submission_credit(submission, deadline, late_fraction):
1727    """
1728    Helper for updating submission group credit based on timeliness
1729    given the specified `deadline`. Sets the "on_time" and
1730    "group_credit" slots of the submission. "on_time" is based on the
1731    "submitted_at" slot and the specified `deadline`; "group_credit" is
1732    based on the original "group_credit" value (or "credit" value if
1733    there is no "group_credit" value) and is multiplied by the
1734    `late_fraction` if the submission is not on-time.
1735
1736    Modifies the given submission dictionary; does not return anything.
1737    """
1738    # Get base credit value
1739    submission["group_credit"] = (
1740        submission.get(
1741            "group_credit",
1742            submission.get("credit", 0)
1743        )
1744    ) or 0  # this ensures None becomes 0 if there's an explicit None
1745
1746    # Figure out if it was on time or late
1747    if submission["submitted_at"] == "on_time":
1748        on_time = True
1749    elif submission["submitted_at"] == "late":
1750        on_time = False
1751    else:
1752        when = potluck.time_utils.time_from_timestring(
1753            submission["submitted_at"]
1754        )
1755        on_time = when <= deadline
1756
1757    # Update submission & possibly credit
1758    submission["on_time"] = on_time
1759    if not on_time:
1760        submission["group_credit"] *= late_fraction
1761
1762
1763def fetch_best_outcomes(
1764    course,
1765    semester,
1766    username,
1767    exercise,
1768    deadline,
1769    late_fraction
1770):
1771    """
1772    Fetches the best outcome information for a particular
1773    course/semester/user/exercise. To do that, it needs to know what
1774    that user's current deadline is, and what credit multiplier to apply
1775    to late submissions. The deadline should be given as a
1776    `datetime.datetime` object. If the user has any manual overrides, the
1777    most recent of those will be returned.
1778
1779    The result will be a dictionary with the following keys:
1780
1781    - "submitted_at" - A time string (see
1782        `potluck.time_utils.timestring`) indicating when the list of
1783        outcomes was submitted. For overrides, might be the string
1784        "on_time" or the string "late" to indicate a specific lateness
1785        value regardless of deadline.
1786    - "on_time" - A boolean indicating whether the submission came in on
1787        time or not, based on the deadline given and the submission
1788        time.
1789    - "authors" - A list of usernames for participating authors, OR a
1790        list containing just the person entering the override for an
1791        override.
1792    - "outcomes" - A list of 3-tuple outcomes, which contain a boolean
1793        for success/failure followed by tag and message strings (see
1794        `optimism.listOutcomesInSuite`). For overrides, this is instead
1795        a Markdown string explaining things.
1796    - "code" - A list of filename/code-string pairs indicating any code
1797        blocks attached to the submission. For overrides, this is the
1798        special string "__override__" to indicate that they're overrides.
1799    - "status" - A status string describing the exercise status based on
1800        the outcomes which passed/failed.
1801    - "credit" - A number indicating how much credit this exercise is
1802        worth (higher = better) or possibly `None` if there is some issue
1803        with the submission (like wrong # of outcomes). This does not take
1804        timeliness into account.
1805    - "group_credit" - A credit number that accounts for timeliness, and
1806        which will always be a number (it's 0 when `credit` would be `None`).
1807
1808    Returns None if an error is encountered, or if the user has no
1809    submissions for that exercise.
1810    """
1811    best = None
1812    overrides = fetch_outcomes(
1813        course,
1814        semester,
1815        username,
1816        exercise,
1817        'override'
1818    )
1819    # Short-circuit and return most-recent override regardless of credit
1820    # if there is at least one.
1821    if overrides is None:
1822        overrides = []
1823    if len(overrides) > 0:
1824        update_submission_credit(overrides[-1], deadline, late_fraction)
1825        return overrides[-1]
1826
1827    fulls = fetch_outcomes(course, semester, username, exercise, 'full')
1828    if fulls is None:
1829        fulls = []
1830    for submission in reversed(fulls):  # iterate backwards chronologically
1831        update_submission_credit(submission, deadline, late_fraction)
1832
1833        if best is None or submission["group_credit"] >= best["group_credit"]:
1834            best = submission
1835            # It this one gets full credit; no need to look for better
1836            if submission["group_credit"] == 1:
1837                break
1838
1839    # Only look at partials if we didn't find a full-credit best
1840    # submission
1841    if best is None or best["group_credit"] < 1:
1842        partials = fetch_outcomes(
1843            course,
1844            semester,
1845            username,
1846            exercise,
1847            'partial'
1848        )
1849        if partials is None:
1850            partials = []
1851        for submission in partials:
1852            update_submission_credit(submission, deadline, late_fraction)
1853
1854            if (
1855                best is None
1856             or submission["group_credit"] >= best["group_credit"]
1857            ):
1858                best = submission
1859
1860    # Only look at no-credit submissions if we have no full or
1861    # partial-credit submissions at all.
1862    if best is None:
1863        nones = fetch_outcomes(
1864            course,
1865            semester,
1866            username,
1867            exercise,
1868            'none'
1869        )
1870        if nones is not None:
1871            # Always take chronologically last one; none of these are worth
1872            # any credit anyways.
1873            best = nones[-1]
1874            update_submission_credit(best, deadline, late_fraction)
1875
1876    # Try legacy info
1877    if best is None or best["group_credit"] < 1:
1878        legacy = fetch_old_outcomes(
1879            course,
1880            semester,
1881            username,
1882            exercise
1883        )
1884        if legacy is None:
1885            legacy = []
1886        for submission in legacy:
1887            old_on_time = submission.get("on_time", True)
1888            # figure out unpenalized credit if it had been marked late
1889            if old_on_time is False:
1890                submission["group_credit"] /= late_fraction
1891
1892            update_submission_credit(submission, deadline, late_fraction)
1893
1894            if (
1895                best is None
1896             or submission["group_credit"] >= best["group_credit"]
1897            ):
1898                best = submission
1899
1900    return best  # might still be None
1901
1902
1903def save_outcomes(
1904    course,
1905    semester,
1906    username,
1907    exercise,
1908    authors,
1909    outcomes,
1910    codeBlocks,
1911    status,
1912    credit,
1913    group_credit
1914):
1915    """
1916    Saves a list of outcomes for a specific exercise submitted by a
1917    particular user who is taking a course in a certain semester. The
1918    outcomes list should be a list of 3-tuples each consisting of a
1919    boolean, a tag string, and a message string (e.g., the return value
1920    from `optimism.listOutcomesInSuite`). The authors value should be a
1921    list of username strings listing all authors who contributed. The
1922    `codeBlocks` value should be a list of pairs, each of which has a
1923    filename string and a code string (the filename could also elsehow
1924    identify the source code was derived from). The `status` value should
1925    be a string describing the status of the submission, while the credit
1926    value should be a number between 0 and 1 (inclusive) where a higher
1927    number indicates a better submission.
1928
1929    The list of outcomes and associated code blocks is added to the
1930    record of all such lists submitted for that exercise by that user,
1931    categorized as 'none', 'partial', or 'full' depending on whether the
1932    credit value is 0, between 0 and 1, or 1. It will be stored as a
1933    dictionary with the following slots:
1934
1935    - "submitted_at": the current time, as a string (see
1936        `potluck.time_utils.timestring`).
1937    - "authors": The list of authors.
1938    - "outcomes": The list of outcomes.
1939    - "code": The list of filename/code-string pairs.
1940    - "status": The status string.
1941    - "credit": The credit number.
1942    - "group_credit": The credit number for counting group credit.
1943
1944    Returns True if it succeeds or False if it encounters some sort of
1945    error.
1946    """
1947    category = 'none'
1948    if group_credit > 1:
1949        raise ValueError(
1950            "Invalid group_credit value '{}' (must be <= 1).".format(
1951                group_credit
1952            )
1953        )
1954    elif group_credit == 1:
1955        category = 'full'
1956    elif group_credit > 0:
1957        category = 'partial'
1958
1959    # Redis key to use
1960    key = exercise_key(course, semester, username, exercise, category)
1961
1962    # Generate a timestamp for the info
1963    timestring = potluck.time_utils.timestring()
1964
1965    # Get old outcomes so we can add to them
1966    recorded_outcomes = fetch_outcomes(
1967        course,
1968        semester,
1969        username,
1970        exercise,
1971        category
1972    )
1973    if recorded_outcomes is None:
1974        recorded_outcomes = []
1975
1976    # Here's the info we store
1977    info = {
1978        "submitted_at": timestring,
1979        "authors": authors,
1980        "outcomes": outcomes,
1981        "code": codeBlocks,
1982        "status": status,
1983        "credit": credit,
1984        "group_credit": group_credit
1985    }
1986
1987    recorded_outcomes.append(info)
1988
1989    new_encoded = json.dumps(recorded_outcomes)
1990
1991    try:
1992        success = _REDIS.set(key, new_encoded)
1993        if success is not True:
1994            raise ValueError("Redis result indicated failure.")
1995    except Exception:
1996        flask.flash("Failed to write outcomes info!")
1997        tb = potluck.html_tools.string_traceback()
1998        print(
1999            "Failed to write outcomes info at '{}':\n{}".format(
2000                key,
2001                tb
2002            ),
2003            file=sys.stderr
2004        )
2005        return False
2006
2007    return True
2008
2009
2010def save_outcomes_override(
2011    course,
2012    semester,
2013    username,
2014    exercise,
2015    overrider,
2016    note,
2017    status,
2018    credit,
2019    time_override=None
2020):
2021    """
2022    Saves an outcome override for a specific exercise submitted by a
2023    particular user who is taking a course in a certain semester.
2024
2025    The `overrider` should be the username of the person entering the
2026    override. The `note` must be a string, and will be rendered using
2027    Markdown to appear on the user's detailed view of the exercise in
2028    question.
2029
2030    The status and credit values are the same as for `save_outcomes`:
2031    `status` is a status string and `credit` is a floating-point number
2032    between 0 and 1 (inclusive).
2033
2034    If `time_override` is provided, it should be one of the strings
2035    "on_time" or "late" and the exercise will be marked as such
2036    regardless of the relationship between the deadline and the
2037    submission time. Note that the late penalty will be applied to the
2038    credit value for overrides which are marked as late.
2039
2040    TODO: Not that?
2041
2042    Only the most recent outcome override applies to a student's grade,
2043    but all outcome overrides will be visible to them.
2044    TODO: Allow for deleting/editing them!
2045
2046    The outcome override will be stored as a dictionary with the
2047    following slots:
2048
2049    - "submitted_at": the current time, as a string (see
2050        `potluck.time_utils.timestring`) or the provided `time_override`
2051        value.
2052    - "authors": A list containing just the `overrider`.
2053    - "outcomes": The `note` string.
2054    - "code": The special value `"__override__"` to mark this as an
2055        override.
2056    - "status": The provided status string.
2057    - "credit": The provided credit number.
2058    - "group_credit": A second copy of the credit number.
2059
2060    It is always stored in the "override" category outcomes storage.
2061
2062    Returns True if it succeeds or False if it encounters some sort of
2063    error.
2064    """
2065    # Redis key to use
2066    key = exercise_key(course, semester, username, exercise, 'override')
2067
2068    # Generate a timestamp for the info
2069    if time_override is None:
2070        timestring = potluck.time_utils.timestring()
2071    else:
2072        timestring = time_override
2073
2074    # Get old outcomes so we can add to them
2075    recorded_outcomes = fetch_outcomes(
2076        course,
2077        semester,
2078        username,
2079        exercise,
2080        'override'
2081    )
2082    if recorded_outcomes is None:
2083        recorded_outcomes = []
2084
2085    # Here's the info we store
2086    info = {
2087        "submitted_at": timestring,
2088        "authors": [overrider],
2089        "outcomes": note,
2090        "code": "__override__",
2091        "status": status,
2092        "credit": credit,
2093        "group_credit": credit
2094    }
2095
2096    recorded_outcomes.append(info)
2097
2098    new_encoded = json.dumps(recorded_outcomes)
2099
2100    try:
2101        success = _REDIS.set(key, new_encoded)
2102        if success is not True:
2103            raise ValueError("Redis result indicated failure.")
2104    except Exception:
2105        flask.flash("Failed to write exercise override info!")
2106        tb = potluck.html_tools.string_traceback()
2107        print(
2108            "Failed to write exercise override info at '{}':\n{}".format(
2109                key,
2110                tb
2111            ),
2112            file=sys.stderr
2113        )
2114        return False
2115
2116    return True
2117
2118
2119def default_feedback_summary():
2120    """
2121    Returns a default summary object. The summary is a pared-down version
2122    of the full feedback .json file that stores the result of
2123    `potluck.render.render_report`, which in turn comes mostly from
2124    `potluck.rubrics.Rubric.evaluate`.
2125    """
2126    return {
2127        "submitted": False, # We didn't find any feedback file!
2128        "timestamp": "(not evaluated)",
2129        "partner_username": None,
2130        "evaluation": "not evaluated",
2131        "warnings": [ "We found no submission for this task." ],
2132        "is_default": True
2133    }
2134
2135
2136def get_feedback_summary(
2137    course,
2138    semester,
2139    task_info,
2140    username,
2141    phase,
2142    prid,
2143    taskid
2144):
2145    """
2146    This retrieves just the feedback summary information that appears on
2147    the dashboard for a given user/phase/project/task. That much info is
2148    light enough to cache, so we do cache it to prevent hitting the disk
2149    a lot for each dashboard view.
2150    """
2151    ts, log_file, report_file, status = get_inflight(
2152        course,
2153        semester,
2154        username,
2155        phase,
2156        prid,
2157        taskid
2158    )
2159    fallback = default_feedback_summary()
2160    if ts in (None, "error"):
2161        return fallback
2162    try:
2163        return load_or_get_cached(
2164            report_file,
2165            view=AsFeedbackSummary,
2166            missing=fallback,
2167            assume_fresh=_CONFIG.get("ASSUME_FRESH", 1)
2168        )
2169    except Exception:
2170        flask.flash("Failed to summarize feedback file.")
2171        return fallback
2172
2173
2174def get_feedback(
2175    course,
2176    semester,
2177    task_info,
2178    username,
2179    phase,
2180    prid,
2181    taskid
2182):
2183    """
2184    Gets feedback for the user's latest pre-deadline submission for the
2185    given phase/project/task. Instead of caching these values (which
2186    would be expensive memory-wise over time) we hit the disk every
2187    time.
2188
2189    Returns a dictionary with at least a 'status' entry. This will be
2190    'ok' if the report was read successfully, or 'missing' if the report
2191    file could not be read or did not exist. If the status is
2192    'missing', a 'log' entry will be present with the contents of the
2193    associated log file, or the string 'missing' if that log file could
2194    also not be read.
2195
2196    Weird stuff could happen if the file is being written as we make the
2197    request. Typically a second attempt should not re-encounter such an
2198    error.
2199    """
2200    result = { "status": "unknown" }
2201    ts, log_file, report_file, status = get_inflight(
2202        course,
2203        semester,
2204        username,
2205        phase,
2206        prid,
2207        taskid
2208    )
2209    if ts is None: # No submission
2210        result["status"] = "missing"
2211    elif ts == "error": # Failed to read inflight file
2212        flask.flash(
2213            "Failed to fetch in-flight info; please refresh the page."
2214        )
2215        result["status"] = "missing"
2216
2217    if result["status"] != "missing":
2218        try:
2219            if not os.path.exists(report_file):
2220                result["status"] = "missing"
2221            else:
2222                with open(report_file, 'r') as fin:
2223                    result = json.load(fin)
2224                    result["status"] = "ok"
2225        except Exception:
2226            flask.flash("Failed to read feedback file.")
2227            tb = potluck.html_tools.string_traceback()
2228            print(
2229                "Failed to read feedback file '{}':\n{}".format(
2230                    report_file,
2231                    tb
2232                ),
2233                file=sys.stderr
2234            )
2235            result["status"] = "missing"
2236
2237    if result["status"] == "ok":
2238        # Polish up warnings/evaluation a tiny bit
2239        warnings = result.get("warnings", [])
2240        evaluation = result.get("evaluation", "not evaluated")
2241        if evaluation == "incomplete" and len(warnings) == 0:
2242            warnings.append(
2243                "Your submission is incomplete"
2244              + " (it did not satisfy even half of the core goals)."
2245            )
2246        result["evaluation"] = evaluation
2247        result["warnings"] = warnings
2248        result["submitted"] = True
2249
2250    # Try to read log file if we couldn't get a report
2251    if result["status"] == "missing":
2252        if log_file is None:
2253            result["log"] = "no submission was made"
2254        else:
2255            try:
2256                if not os.path.exists(log_file):
2257                    result["log"] = "missing"
2258                else:
2259                    with open(log_file, 'r') as fin:
2260                        result["log"] = fin.read()
2261            except Exception:
2262                flask.flash("Error reading log file.")
2263                tb = potluck.html_tools.string_traceback()
2264                print(
2265                    "Failed to read log file '{}':\n{}".format(log_file, tb),
2266                    file=sys.stderr
2267                )
2268                result["log"] = "missing"
2269
2270    return result
2271
2272
2273def get_feedback_html(
2274    course,
2275    semester,
2276    task_info,
2277    username,
2278    phase,
2279    prid,
2280    taskid
2281):
2282    """
2283    Gets feedback for the user's latest pre-deadline submission for the
2284    given phase/project/task, as html instead of as json (see
2285    `get_feedback`). Instead of caching these values (which would be
2286    expensive memory-wise over time) we hit the disk every time.
2287
2288    Returns the string "missing" if the relevant feedback file does not
2289    exist, or if some kind of error occurs trying to access the file.
2290
2291    Might encounter an error if the file is being written as we try to
2292    read it.
2293    """
2294    result = None
2295    ts, log_file, report_file, status = get_inflight(
2296        course,
2297        semester,
2298        username,
2299        phase,
2300        prid,
2301        taskid
2302    )
2303    if ts is None: # No submission
2304        result = "missing"
2305    elif ts == "error": # Failed to read inflight file
2306        flask.flash(
2307            "Failed to read in-flight info; please refresh the page."
2308        )
2309        result = "missing"
2310
2311    if result != "missing":
2312        html_file = report_file[:-5] + ".html"
2313        try:
2314            if os.path.exists(html_file):
2315                # These include student code, instructions, etc., so it
2316                # would be expensive to cache them.
2317                with open(html_file, 'r') as fin:
2318                    result = fin.read()
2319                result = AsFeedbackHTML.decode(result)
2320            else:
2321                result = "missing"
2322        except Exception:
2323            flask.flash("Failed to read feedback report.")
2324            tb = potluck.html_tools.string_traceback()
2325            print(
2326                "Failed to read feedback report '{}':\n{}".format(
2327                    html_file,
2328                    tb
2329                ),
2330                file=sys.stderr
2331            )
2332            result = "missing"
2333
2334    return result
2335
2336
2337#-------#
2338# Views #
2339#-------#
2340
2341class View:
2342    """
2343    Abstract View class to organize decoding/encoding of views. Each View
2344    must define encode and decode class methods which are each others'
2345    inverse. The class name is used as part of the cache key. For
2346    read-only views, a exception (e.g., NotImplementedError) should be
2347    raised in the encode method.
2348
2349    Note that the decode method may be given None as a parameter in
2350    situations where a file doesn't exist, and in most cases should
2351    simply pass that value through.
2352    """
2353    @staticmethod
2354    def encode(obj):
2355        """
2356        The encode function of a View must return a string (to be written
2357        to a file).
2358        """
2359        raise NotImplementedError("Don't use the base View class.")
2360
2361    @staticmethod
2362    def decode(string):
2363        """
2364        The encode function of a View must accept a string, and if given
2365        a string produced by encode, should return an equivalent object.
2366        """
2367        raise NotImplementedError("Don't use the base View class.")
2368
2369
2370class AsIs(View):
2371    """
2372    A pass-through view that returns strings unaltered.
2373    """
2374    @staticmethod
2375    def encode(obj):
2376        """Returns the object it is given unaltered."""
2377        return obj
2378
2379    @staticmethod
2380    def decode(string):
2381        """Returns the string it is given unaltered."""
2382        return string
2383
2384
2385class AsJSON(View):
2386    """
2387    A view that converts objects to JSON for file storage and back on
2388    access. It passes through None.
2389    """
2390    @staticmethod
2391    def encode(obj):
2392        """Returns the JSON encoding of the object."""
2393        return json.dumps(obj)
2394
2395    @staticmethod
2396    def decode(string):
2397        """
2398        Returns a JSON object parsed from the string.
2399        Returns None if it gets None.
2400        """
2401        if string is None:
2402            return None
2403        return json.loads(string)
2404
2405
2406def build_view(name, encoder, decoder, pass_none=True):
2407    """
2408    Function for building a view given a name, an encoding function, and
2409    a decoding function. Unless pass_none is given as False, the decoder
2410    will be skipped if the decode argument is None and the None will pass
2411    through, in which case the decoder will *always* get a string as an
2412    argument.
2413    """
2414    class SyntheticView(View):
2415        """
2416        View class created using build_view.
2417        """
2418        @staticmethod
2419        def encode(obj):
2420            return encoder(obj)
2421
2422        @staticmethod
2423        def decode(string):
2424            if pass_none and string is None:
2425                return None
2426            return decoder(string)
2427
2428    SyntheticView.__name__ = name
2429    SyntheticView.__doc__ = (
2430        "View that uses '{}' for encoding and '{}' for decoding."
2431    ).format(encoder.__name__, decoder.__name__)
2432    SyntheticView.encode.__doc__ = encoder.__doc__
2433    SyntheticView.decode.__doc__ = decoder.__doc__
2434
2435    return SyntheticView
2436
2437
2438class AsStudentInfo(View):
2439    """
2440    Encoding and decoding for TSV student info files, which are cached.
2441    The student info structure is a dictionary mapping usernames to
2442    additional student info.
2443    """
2444    @staticmethod
2445    def encode(obj):
2446        """
2447        Student info *cannot* be encoded, because we are not interested
2448        in writing it to a file.
2449        TODO: Student info editing in-app?
2450        """
2451        raise NotImplementedError(
2452            "Cannot encode student info: student info is read-only."
2453        )
2454
2455    @staticmethod
2456    def decode(string):
2457        """
2458        Extra student info is read from a student info file by extracting
2459        the text, loading it as Excel-TSV data, and turning it into a
2460        dictionary where each student ID maps to a dictionary containing
2461        the columns as keys with values from that column as values.
2462        """
2463        reader = csv.DictReader(
2464            (line for line in string.strip().split('\n')),
2465            dialect="excel-tab"
2466        )
2467        result = {}
2468        for row in reader:
2469            entry = {}
2470            # TODO: Get this remap on a per-course basis!!!
2471            for key in _CONFIG["REMAP_STUDENT_INFO"]:
2472                entry[_CONFIG["REMAP_STUDENT_INFO"][key]] = row.get(key)
2473            entry["username"] = entry["email"].split('@')[0]
2474            result[entry['username']] = entry
2475        return result
2476
2477
2478class AsRoster(View):
2479    """
2480    Encoding and decoding for CSV rosters, which are cached. The roster
2481    structure is a dictionary mapping usernames to student info (see
2482    `load_roster_from_stream`).
2483    """
2484    @staticmethod
2485    def encode(obj):
2486        """
2487        A roster *cannot* be encoded, because we are not interested in
2488        writing it to a file.
2489        TODO: Roster editing in-app?
2490        """
2491        raise NotImplementedError(
2492            "Cannot encode a roster: rosters are read-only."
2493        )
2494
2495    @staticmethod
2496    def decode(string):
2497        """
2498        A roster is read from a roaster file by extracting the text and
2499        running it through `load_roster_from_stream`.
2500        """
2501        lines = string.strip().split('\n')
2502        return load_roster_from_stream(lines)
2503
2504
2505class AsFeedbackHTML(View):
2506    """
2507    Encoding and decoding for feedback HTML files (we extract the body
2508    contents).
2509    """
2510    @staticmethod
2511    def encode(obj):
2512        """
2513        Feedback HTML *cannot* be encoded, because we want it to be
2514        read-only: it's produced by running potluck_eval, and the server
2515        won't edit it.
2516        """
2517        raise NotImplementedError(
2518            "Cannot encode feedback HTML: feedback is read-only."
2519        )
2520
2521    @staticmethod
2522    def decode(string):
2523        """
2524        Feedback HTML is read from the raw HTML file by extracting the
2525        innerHTML of the body tag using Beautiful Soup. Returns a default
2526        string if the file wasn't found.
2527        """
2528        if string is None: # happens when the target file doesn't exist
2529            return "no feedback available"
2530        soup = bs4.BeautifulSoup(string, "html.parser")
2531        body = soup.find("body")
2532        return str(body)
2533
2534
2535class AsFeedbackSummary(View):
2536    """
2537    Encoding and decoding for feedback summaries, which are cached.
2538    """
2539    @staticmethod
2540    def encode(obj):
2541        """
2542        A feedback summary *cannot* be encoded, because it cannot be
2543        written to a file. Feedback summaries are only read from full
2544        feedback files, never written.
2545        """
2546        raise NotImplementedError(
2547            "Cannot encode a feedback summary: summaries are read-only."
2548        )
2549
2550    @staticmethod
2551    def decode(string):
2552        """
2553        A feedback summary is read from a feedback file by extracting the
2554        full JSON feedback and then paring it down to just the essential
2555        information for the dashboard view.
2556        """
2557        if string is None: # happens when the target file doesn't exist
2558            return default_feedback_summary()
2559        # Note taskid is nonlocal here
2560        raw_report = json.loads(string)
2561        warnings = raw_report.get("warnings", [])
2562        evaluation = raw_report.get("evaluation", "not evaluated")
2563        if evaluation == "incomplete" and len(warnings) == 0:
2564            warnings.append(
2565                "Your submission is incomplete"
2566              + " (it did not satisfy even half of the core goals)."
2567            )
2568        return {
2569            "submitted": True,
2570            "partner_username": raw_report.get("partner_username"),
2571            "timestamp": raw_report.get("timestamp"),
2572            "evaluation": evaluation,
2573            "warnings": warnings,
2574            "is_default": False
2575            # report summary, files, table, and contexts omitted
2576        }
2577
2578
2579#------------------------#
2580# Read-only file caching #
2581#------------------------#
2582
2583# Note: by using a threading.RLock and a global variable here, we are not
2584# process-safe, which is fine, because this is just a cache: each process
2585# in a multi-process environment can safely maintain its own cache which
2586# will waste a bit of memory but not lead to corruption. As a corollary,
2587# load_or_get_cached should be treated as read-only, otherwise one
2588# process might write to a file that's being read leading to excessively
2589# interesting behavior.
2590
2591# TODO: We should probably have some kind of upper limit on the cache
2592# size, and maintain staleness so we can get rid of stale items...
2593_CACHE_LOCK = threading.RLock() # Make this reentrant just in case...
2594_CACHE = {}
2595"""
2596Cache of objects returned by view functions on cache keys.
2597"""
2598
2599
2600_FRESH_AT = {}
2601"""
2602Mapping from cache keys to tuples of seconds-since-epoch floats
2603representing the most recent time-span during which the file was found to
2604be unchanged. The first element of each tuple represents the earliest time
2605at which a freshness check succeeded with no subsequent failed checks,
2606and the second element represents the most recent time another successful
2607check was performed. Whenever a check is performed and succeeds, the
2608second element is updated, and whenever a check is performed and fails,
2609the value is set to None. When the value is None and a check succeeds,
2610both elements of the tuple are set to the current time.
2611"""
2612
2613
2614def build_file_freshness_checker(
2615    missing=Exception,
2616    assume_fresh=0,
2617    cache={}
2618):
2619    """
2620    Builds a freshness checker that checks the mtime of a filename, but
2621    if that file doesn't exist, it returns AbortGeneration with the given
2622    missing value (unless missing is left as the default of Exception, in
2623    which case it lets the exception bubble out).
2624
2625    If assume_fresh is set to a positive number, and less than that many
2626    seconds have elapsed since the most recent mtime check, the mtime
2627    check is skipped and the file is assumed to be fresh.
2628    """
2629    ck = (id(missing), assume_fresh)
2630    if ck in cache:
2631        return cache[ck]
2632
2633    def check_file_is_changed(cache_key, ts):
2634        """
2635        Checks whether a file has been modified more recently than the given
2636        timestamp.
2637        """
2638        global _FRESH_AT
2639        now = time.time()
2640        cached_fresh = _FRESH_AT.get(cache_key)
2641        if cached_fresh is not None:
2642            cached_start, cached_end = cached_fresh
2643            if (
2644                ts is not None
2645            and ts >= cached_start
2646                # Note we don't check ts <= cached_end here
2647            and (now - cached_end) < _CONFIG.get("ASSUME_FRESH", 1)
2648            ):
2649                return False  # we assume it has NOT changed
2650
2651        filename = cache_key_filename(cache_key)
2652        try:
2653            mtime = os.path.getmtime(filename)
2654        except OSError_or_FileNotFoundError:
2655            if missing == Exception:
2656                raise
2657            else:
2658                return AbortGeneration(missing)
2659
2660        # File is changed if the mtime is after the given cache
2661        # timestamp, or if the timestamp is None
2662        result = ts is None or mtime >= ts
2663        if result:
2664            # If we find that a specific time was checked, and that time
2665            # was after the beginning of the current cached fresh period,
2666            # we erase the cached fresh period, since result being true
2667            # means the file HAS changed.
2668            if (
2669                ts is not None
2670            and (
2671                    cached_fresh is not None
2672                and ts > cached_start
2673                )
2674            ):
2675                _FRESH_AT[cache_key] = None
2676        else:
2677            # If the file WASN'T changed, we extend the cache freshness
2678            # (In this branch ts is NOT None and it's strictly greater
2679            # than mtime)
2680            if cached_fresh is not None:
2681                # If an earlier-time-point was checked and the check
2682                # succeeded, we can extend the fresh-time-span backwards
2683                if ts < cached_start:
2684                    cached_start = ts
2685
2686                # Likewise, we might be able to extend it forwards
2687                if ts > cached_end:
2688                    cached_end = ts
2689
2690                # Update the time-span
2691                _FRESH_AT[cache_key] = (cached_start, cached_end)
2692            else:
2693                # If we didn't have cached freshness, initialize it
2694                _FRESH_AT[cache_key] = (ts, ts)
2695
2696        return result
2697
2698    cache[ck] = check_file_is_changed
2699    return check_file_is_changed
2700
2701
2702def build_file_reader(view=AsJSON):
2703    """
2704    Builds a file reader function which returns the result of the given
2705    view on the file contents.
2706    """
2707    def read_file(cache_key):
2708        """
2709        Reads a file and returns the result of calling a view's decode
2710        function on the file contents. Returns None if there's an error,
2711        and prints the error unless it's a FileNotFoundError.
2712        """
2713        filename = cache_key_filename(cache_key)
2714        try:
2715            with open(filename, 'r') as fin:
2716                return view.decode(fin.read())
2717        except IOError_or_FileNotFoundError:
2718            return None
2719        except Exception as e:
2720            sys.stderr.write(
2721                "[sync module] Exception viewing file:\n" + str(e) + '\n'
2722            )
2723            return None
2724
2725    return read_file
2726
2727
2728#--------------------#
2729# File I/O functions #
2730#--------------------#
2731
2732def cache_key_for(target, view):
2733    """
2734    Builds a hybrid cache key value with a certain target and view. The
2735    target filename must not include '::'.
2736    """
2737    if '::' in target:
2738        raise ValueError(
2739            "Cannot use a filename with a '::' in it as the target"
2740            " file."
2741        )
2742    return target + '::' + view.__name__
2743
2744
2745def cache_key_filename(cache_key):
2746    """
2747    Returns just the filename given a cache key.
2748    """
2749    filename = None
2750    for i in range(len(cache_key) - 1):
2751        if cache_key[i:i + 2] == '::':
2752            filename = cache_key[:i]
2753            break
2754    if filename is None:
2755        raise ValueError("Value '{}' is not a cache key!".format(cache_key))
2756
2757    return filename
2758
2759
2760def load_or_get_cached(
2761    filename,
2762    view=AsJSON,
2763    missing=Exception,
2764    assume_fresh=0
2765):
2766    """
2767    Reads the given file, returning its contents as a string. Doesn't
2768    actually do that most of the time. Instead, it will return a cached
2769    value. And instead of returning the contents of the file as a
2770    string, it returns the result of running the given view function on
2771    the file's contents (decoded as a string). And actually, it caches
2772    the view result, not the file contents, to save time reapplying the
2773    view. The default view is AsJSON, which loads the file contents as
2774    JSON and creates a Python object.
2775
2776    The __name__ of the view class will be used to compute a cache key
2777    for that view; avoid view name collisions.
2778
2779    If the file on disk is newer than the cache, re-reads and re-caches
2780    the file. If assume_fresh is set to a positive number, then the file
2781    time on disk isn't even checked if the most recent check was
2782    performed less than that many seconds ago.
2783
2784    If the file is missing, an exception would normally be raised, but
2785    if the `missing` value is provided as something other than
2786    `Exception`, a deep copy of that value will be returned instead.
2787
2788    Note: On a cache hit, a deep copy of the cached value is returned, so
2789    modifying that value should not affect what is stored in the cache.
2790    """
2791
2792    # Figure out our view object (& cache key):
2793    if view is None:
2794        view = AsIs
2795
2796    cache_key = cache_key_for(filename, view)
2797
2798    # Build functions for checking freshness and reading the file
2799    check_mtime = build_file_freshness_checker(missing, assume_fresh)
2800    read_file = build_file_reader(view)
2801
2802    return _gen_or_get_cached(
2803        _CACHE_LOCK,
2804        _CACHE,
2805        cache_key,
2806        check_mtime,
2807        read_file
2808    )
2809
2810
2811#----------------------#
2812# Core caching routine #
2813#----------------------#
2814
2815class AbortGeneration:
2816    """
2817    Class to signal that generation of a cached item should not proceed.
2818    Holds a default value to return instead.
2819    """
2820    def __init__(self, replacement):
2821        self.replacement = replacement
2822
2823
2824class NotInCache:
2825    """
2826    Placeholder for recognizing that a value is not in the cache (when
2827    e.g., None might be a valid cache value).
2828    """
2829    pass
2830
2831
2832def _gen_or_get_cached(
2833    lock,
2834    cache,
2835    cache_key,
2836    check_dirty,
2837    result_generator
2838):
2839    """
2840    Common functionality that uses a reentrant lock and a cache
2841    dictionary to return a cached value if the cached value is fresh. The
2842    value from the cache is deep-copied before being returned, so that
2843    any modifications to the returned value shouldn't alter the cache.
2844    Parameters are:
2845
2846        lock: Specifies the lock to use. Should be a threading.RLock.
2847        cache: The cache dictionary.
2848        cache_key: String key for this cache item.
2849        check_dirty: Function which will be given the cache_key and a
2850            timestamp and must return True if the cached value (created
2851            at that instant) is dirty (needs to be updated) and False
2852            otherwise. May also return an AbortGeneration instance with a
2853            default value inside to be returned directly. If there is no
2854            cached value, check_dirty will be given a timestamp of None.
2855        result_generator: Function to call to build a new result if the
2856            cached value is stale. This new result will be cached. It
2857            will be given the cache_key as a parameter.
2858    """
2859    with lock:
2860        # We need to read the file contents and return them.
2861        cache_ts, cached = cache.get(cache_key, (None, NotInCache))
2862        safe_cached = copy.deepcopy(cached)
2863
2864    # Use the provided function to check if this cache key is dirty.
2865    # No point in story the missing value we return since the dirty
2866    # check would presumably still come up True in the future.
2867    is_dirty = check_dirty(cache_key, cache_ts)
2868    if isinstance(is_dirty, AbortGeneration):
2869        # check_fresh calls for an abort: return replacement value
2870        return is_dirty.replacement
2871    elif not is_dirty and cached != NotInCache:
2872        # Cache is fresh: return cached value
2873        return safe_cached
2874    else:
2875        # Cache is stale
2876
2877        # Get timestamp before we even start generating value:
2878        ts = time.time()
2879
2880        # Generate reuslt:
2881        result = result_generator(cache_key)
2882
2883        # Safely store new result value + timestamp in cache:
2884        with lock:
2885            cache[cache_key] = (ts, result)
2886            # Don't allow outside code to mess with internals of
2887            # cached value (JSON results could be mutable):
2888            safe_result = copy.deepcopy(result)
2889
2890        # Return fresh deep copy of cached value:
2891        return safe_result
2892
2893
2894#-----------------#
2895# Setup functions #
2896#-----------------#
2897
2898_REDIS = None
2899"""
2900The connection to the REDIS server.
2901"""
2902
2903_CONFIG = None
2904"""
2905The flask app's configuration object.
2906"""
2907
2908
2909def init(config, key=None):
2910    """
2911    `init` should be called once per process, ideally early in the life of
2912    the process, like right after importing the module. Calling
2913    some functions before `init` will fail. A file named 'redis-pw.conf'
2914    should exist unless a key is given (should be a byte-string). If
2915    'redis-pw.conf' doesn't exist, it will be created.
2916    """
2917    global _REDIS, _CONFIG, _EVAL_BASE
2918
2919    # Store config object
2920    _CONFIG = config
2921
2922    # Compute evaluation base directory based on init-time CWD
2923    _EVAL_BASE = os.path.join(os.getcwd(), _CONFIG["EVALUATION_BASE"])
2924
2925    # Grab port from config
2926    port = config.get("STORAGE_PORT", 51723)
2927
2928    # Redis configuration filenames
2929    rconf_file = "potluck-redis.conf"
2930    ruser_file = "potluck-redis-user.acl"
2931    rport_file = "potluck-redis-port.conf"
2932    rpid_file = "potluck-redis.pid"
2933    rlog_file = "potluck-redis.log"
2934
2935    # Check for redis config file
2936    if not os.path.exists(rconf_file):
2937        raise IOError_or_FileNotFoundError(
2938            "Unable to find Redis configuration file '{}'.".format(
2939                rconf_file
2940            )
2941        )
2942
2943    # Check that conf file contains required stuff
2944    # TODO: More flexibility about these things?
2945    with open(rconf_file, 'r') as fin:
2946        rconf = fin.read()
2947        adir = 'aclfile "{}"'.format(ruser_file)
2948        if adir not in rconf:
2949            raise ValueError(
2950                (
2951                    "Redis configuration file '{}' is missing an ACL"
2952                    " file directive for the ACL file. It needs to use"
2953                    " '{}'."
2954                ).format(rconf_file, adir)
2955            )
2956
2957        incl = "include {}".format(rport_file)
2958        if incl not in rconf:
2959            raise ValueError(
2960                (
2961                    "Redis configuration file '{}' is missing an include"
2962                    " for the port file. It needs to use '{}'."
2963                ).format(rconf_file, incl)
2964            )
2965
2966        pdecl = 'pidfile "{}"'.format(rpid_file)
2967        if pdecl not in rconf:
2968            raise ValueError(
2969                (
2970                    "Redis configuration file '{}' is missing the"
2971                    " correct PID file directive '{}'."
2972                ).format(rconf_file, pdecl)
2973            )
2974
2975        ldecl = 'logfile "{}"'.format(rlog_file)
2976        if ldecl not in rconf:
2977            raise ValueError(
2978                (
2979                    "Redis configuration file '{}' is missing the"
2980                    " correct log file directive '{}'."
2981                ).format(rconf_file, ldecl)
2982            )
2983
2984    # Get storage key:
2985    if key is None:
2986        try:
2987            if os.path.exists(ruser_file):
2988                with open(ruser_file, 'r') as fin:
2989                    key = fin.read().strip().split()[-1][1:]
2990            else:
2991                print(
2992                    "Creating new Redis user file '{}'.".format(
2993                        ruser_file
2994                    )
2995                )
2996                # b32encode here makes it more readable
2997                key = base64.b32encode(os.urandom(64)).decode("ascii")
2998                udecl = "user default on +@all ~* >{}".format(key)
2999                with open(ruser_file, 'w') as fout:
3000                    fout.write(udecl)
3001        except Exception:
3002            raise IOError_or_FileNotFoundError(
3003                "Unable to access user file '{}'.".format(ruser_file)
3004            )
3005
3006    # Double-check port, or write port conf file
3007    if os.path.exists(rport_file):
3008        with open(rport_file, 'r') as fin:
3009            portstr = fin.read().strip().split()[1]
3010        try:
3011            portconf = int(portstr)
3012        except Exception:
3013            portconf = portstr
3014
3015        if portconf != port:
3016            raise ValueError(
3017                (
3018                    "Port was specified as {}, but port conf file"
3019                    " already exists and says port should be {}."
3020                    " Delete the port conf file '{}' to re-write it."
3021                ).format(repr(port), repr(portconf), rport_file)
3022            )
3023    else:
3024        # We need to write the port into the config file
3025        with open(rport_file, 'w') as fout:
3026            fout.write("port " + str(port))
3027
3028    _REDIS = redis.Redis(
3029        'localhost',
3030        port,
3031        password=key,
3032        decode_responses=True
3033    )
3034    # Attempt to connect; if that fails, attempt to start a new Redis
3035    # server and attempt to connect again. Abort if we couldn't start
3036    # the server.
3037    print("Attempting to connect to Redis server...")
3038    try:
3039        _REDIS.exists('test') # We just want to not trigger an error
3040        print("...connected successfully.")
3041    except redis.exceptions.ConnectionError: # nobody to connect to
3042        _REDIS = None
3043    except redis.exceptions.ResponseError: # bad password
3044        raise ValueError(
3045            "Your authentication key is not correct. Make sure"
3046            " you're not sharing the port you chose with another"
3047            " process!"
3048        )
3049
3050    if _REDIS is None:
3051        print("...failed to connect...")
3052        if os.path.exists(rpid_file):
3053            print(
3054                (
3055                    "...a Redis PID file already exists at '{}', but we"
3056                    " can't connect. Please shut down the old Redis"
3057                    " server first, or clean up the PID file if it"
3058                    " crashed."
3059                ).format(rpid_file)
3060            )
3061            raise ValueError(
3062                "Aborting server startup due to existing PID file."
3063            )
3064
3065        # Try to start a new redis server...
3066        print("...starting Redis...")
3067        try:
3068            subprocess.Popen(["redis-server", rconf_file])
3069        except OSError:
3070            # If running through e.g. Apache and this command fails, you
3071            # can try to start it manually, so we point that out
3072            if len(shlex.split(rconf_file)) > 1:
3073                rconf_arg = "'" + rconf_file.replace("'", r"\'") + "'"
3074            else:
3075                rconf_arg = rconf_file
3076            sys.stdout.write(
3077                (
3078                    "Note: Failed to start redis-server with an"
3079                    " OSError.\nYou could try to manually launch the"
3080                    " server by running:\nredis-server {}"
3081                ).format(rconf_arg)
3082            )
3083            raise
3084        time.sleep(0.2) # try to connect pretty quickly
3085        print("...we hope Redis is up now...")
3086
3087        if not os.path.exists(rpid_file):
3088            print(
3089                (
3090                    "...looks like Redis failed to launch; check '{}'..."
3091                ).format(rlog_file)
3092            )
3093
3094        # We'll try a second time to connect
3095        _REDIS = redis.Redis(
3096            'localhost',
3097            port,
3098            password=key,
3099            decode_responses=True
3100        )
3101        print("Reattempting connection to Redis server...")
3102        try:
3103            _REDIS.exists('test') # We just want to not get an error
3104            print("...connected successfully.")
3105        except redis.exceptions.ConnectionError: # Not ready yet
3106            print("...not ready on first attempt...")
3107            _REDIS = None
3108        except redis.exceptions.ResponseError: # bad password
3109            raise ValueError(
3110                "Your authentication key is not correct. Make sure"
3111                " you're not sharing the port you chose with another"
3112                " process!"
3113            )
3114
3115        # We'll make one final attempt
3116        if _REDIS is None:
3117            time.sleep(2) # Give it plenty of time
3118
3119            # Check for PID file
3120            if not os.path.exists(rpid_file):
3121                print(
3122                    (
3123                        "...looks like Redis is still not running;"
3124                        " check '{}'..."
3125                    ).format(rlog_file)
3126                )
3127
3128            # Set up connection object
3129            _REDIS = redis.Redis(
3130                'localhost',
3131                port,
3132                password=key,
3133                decode_responses=True
3134            )
3135            # Attempt to connect
3136            print(
3137                "Reattempting connection to Redis server (last"
3138                " chance)..."
3139            )
3140            try:
3141                _REDIS.exists('test') # We just want to not get an error
3142                print("...connected successfully.")
3143            except redis.exceptions.ResponseError: # bad password
3144                raise ValueError(
3145                    "Your authentication key is not correct. Make sure"
3146                    " you're not sharing the port you chose with another"
3147                    " process!"
3148                )
3149            # This time, we'll let a connection error bubble out
3150
3151    # At this point, _REDIS is a working connection.
SCHEMA_VERSION = '1'

The version for the schema used to organize information under keys in Redis. If this changes, all Redis keys will change.

def ensure_directory(target):
66def ensure_directory(target):
67    """
68    makedirs 2/3 shim.
69    """
70    if sys.version_info[0] < 3:
71        try:
72            os.makedirs(target)
73        except OSError:
74            pass
75    else:
76        os.makedirs(target, exist_ok=True)

makedirs 2/3 shim.

def unused_filename(target):
83def unused_filename(target):
84    """
85    Checks whether the target already exists, and if it does, appends _N
86    before the file extension, where N is the smallest positive integer
87    such that the returned filename is not the name of an existing file.
88    If the target does not exists, returns it.
89    """
90    n = 1
91    backup = target
92    base, ext = os.path.splitext(target)
93    while os.path.exists(backup):
94        backup = base + "_" + str(n) + ext
95        n += 1
96
97    return backup

Checks whether the target already exists, and if it does, appends _N before the file extension, where N is the smallest positive integer such that the returned filename is not the name of an existing file. If the target does not exists, returns it.

def make_way_for(target):
100def make_way_for(target):
101    """
102    Given that we're about to overwrite the given file, this function
103    moves any existing file to a backup first, numbering backups starting
104    with _1. The most-recent backup will have the largest backup number.
105
106    After calling this function, the given target file will not exist,
107    and so new material can be safely written there.
108    """
109    backup = unused_filename(target)
110    if backup != target:
111        shutil.move(target, backup)

Given that we're about to overwrite the given file, this function moves any existing file to a backup first, numbering backups starting with _1. The most-recent backup will have the largest backup number.

After calling this function, the given target file will not exist, and so new material can be safely written there.

def evaluation_directory(course, semester):
114def evaluation_directory(course, semester):
115    """
116    The evaluation directory for a particular class/semester.
117    """
118    return os.path.join(
119        _EVAL_BASE,
120        course,
121        semester
122    )

The evaluation directory for a particular class/semester.

def logs_folder(course, semester, username):
125def logs_folder(course, semester, username):
126    """
127    The logs folder for a class/semester/user.
128    """
129    return safe_join(
130        evaluation_directory(course, semester),
131        "logs",
132        username
133    )

The logs folder for a class/semester/user.

def reports_folder(course, semester, username):
136def reports_folder(course, semester, username):
137    """
138    The reports folder for a class/semester/user.
139    """
140    return safe_join(
141        evaluation_directory(course, semester),
142        "reports",
143        username
144    )

The reports folder for a class/semester/user.

def submissions_folder(course, semester):
147def submissions_folder(course, semester):
148    """
149    The submissions folder for a class/semester.
150    """
151    return os.path.join(
152        evaluation_directory(course, semester),
153        "submissions"
154    )

The submissions folder for a class/semester.

def admin_info_file(course, semester):
157def admin_info_file(course, semester):
158    """
159    The admin info file for a class/semester.
160    """
161    return os.path.join(
162        evaluation_directory(course, semester),
163        _CONFIG["ADMIN_INFO_FILE"]
164    )

The admin info file for a class/semester.

def task_info_file(course, semester):
167def task_info_file(course, semester):
168    """
169    The task info file for a class/semester.
170    """
171    return os.path.join(
172        evaluation_directory(course, semester),
173        _CONFIG.get("TASK_INFO_FILE", "tasks.json")
174    )

The task info file for a class/semester.

def concepts_file(course, semester):
177def concepts_file(course, semester):
178    """
179    The concepts file for a class/semester.
180    """
181    return os.path.join(
182        evaluation_directory(course, semester),
183        _CONFIG.get("CONCEPTS_FILE", "pl_concepts.json")
184    )

The concepts file for a class/semester.

def roster_file(course, semester):
187def roster_file(course, semester):
188    """
189    The roster file for a class/semester.
190    """
191    return os.path.join(
192        evaluation_directory(course, semester),
193        _CONFIG["ROSTER_FILE"]
194    )

The roster file for a class/semester.

def student_info_file(course, semester):
197def student_info_file(course, semester):
198    """
199    The student info file for a class/semester.
200    """
201    return os.path.join(
202        evaluation_directory(course, semester),
203        _CONFIG["STUDENT_INFO_FILE"]
204    )

The student info file for a class/semester.

def redis_key(suffix):
211def redis_key(suffix):
212    """
213    Given a key suffix, returns a full Redis key which includes
214    "potluck:<version>" where version is the schema version (see
215    `SCHEMA_VERSION`).
216    """
217    return "potluck:" + SCHEMA_VERSION + ":" + suffix

Given a key suffix, returns a full Redis key which includes "potluck:" where version is the schema version (see SCHEMA_VERSION).

def redis_key_suffix(key):
220def redis_key_suffix(key):
221    """
222    Returns the part of a Redis key that wasn't added by the `redis_key`
223    function.
224    """
225    return key[len(redis_key("")):]

Returns the part of a Redis key that wasn't added by the redis_key function.

def inflight_key(course, semester, username, project, task, phase):
228def inflight_key(course, semester, username, project, task, phase):
229    """
230    The in-flight key for a class/semester/user/project/task/phase.
231    """
232    return redis_key(
233        ':'.join(
234            [
235                course,
236                semester,
237                "inflight",
238                username,
239                project,
240                task,
241                phase
242            ]
243        )
244    )

The in-flight key for a class/semester/user/project/task/phase.

def extension_key(course, semester, username, project, phase):
247def extension_key(course, semester, username, project, phase):
248    """
249    The Redis key for the extension for a
250    class/semester/user/project/phase.
251    """
252    return redis_key(
253        ':'.join([course, semester, "ext", username, project, phase])
254    )

The Redis key for the extension for a class/semester/user/project/phase.

def time_spent_key(course, semester, username, project, phase, task):
257def time_spent_key(course, semester, username, project, phase, task):
258    """
259    The Redis key for the time-spent info for a
260    class/semester/user/project/phase/task.
261    """
262    return redis_key(
263        ':'.join(
264            [course, semester, "spent", username, project, phase, task]
265        )
266    )

The Redis key for the time-spent info for a class/semester/user/project/phase/task.

def evaluation_key(course, semester, username, project, phase, task):
269def evaluation_key(course, semester, username, project, phase, task):
270    """
271    The Redis key for the custom evaluation info for a
272    class/semester/user/project/phase/task.
273    """
274    return redis_key(
275        ':'.join(
276            [course, semester, "eval", username, project, phase, task]
277        )
278    )

The Redis key for the custom evaluation info for a class/semester/user/project/phase/task.

def egroup_override_key(course, semester, username, egroup):
281def egroup_override_key(course, semester, username, egroup):
282    """
283    The Redis key for the grade override info for a
284    class/semester/user/egroup
285    """
286    return redis_key(
287        ':'.join(
288            [course, semester, "egover", username, egroup]
289        )
290    )

The Redis key for the grade override info for a class/semester/user/egroup

def old_exercise_key(course, semester, username, exercise):
293def old_exercise_key(course, semester, username, exercise):
294    """
295    Old-format exercises key.
296    """
297    return redis_key(
298        ':'.join(
299            [course, semester, "outcomes", username, exercise]
300        )
301    )

Old-format exercises key.

def exercise_key(course, semester, username, exercise, category):
304def exercise_key(course, semester, username, exercise, category):
305    """
306    The Redis key for the outcomes-list-history for a particular
307    exercise, submitted by a user for a particular course/semester.
308    These are further split by category: no-credit, partial-credit, and
309    full-credit exercises are stored in different history lists.
310    """
311    return redis_key(
312        ':'.join(
313            [course, semester, "outcomes", username, exercise, category]
314        )
315    )

The Redis key for the outcomes-list-history for a particular exercise, submitted by a user for a particular course/semester. These are further split by category: no-credit, partial-credit, and full-credit exercises are stored in different history lists.

def get_variable_field_value(titles, row, name_options):
322def get_variable_field_value(titles, row, name_options):
323    """
324    Extracts info from a CSV row that might be found in different columns
325    (or combinations of columns).
326
327    Needs a list of column titles (lowercased strings), plus a name
328    options list, and the row to extract from. Each item in the name
329    options list is either a string (column name in lowercase), or a
330    tuple containing one or more strings followed by a function to be
331    called with the values of those columns as arguments whose result
332    will be used as the value.
333
334    Returns the value from the row as extracted by the extraction
335    function, or simply looked up in the case of a string entry. Tries
336    options from the first to the last and returns the value from the
337    first one that worked.
338    """
339    obj = {}
340    val = obj
341    for opt in name_options:
342        if isinstance(opt, str):
343            try:
344                val = row[titles.index(opt)]
345            except ValueError:
346                continue
347        else:
348            try:
349                args = [row[titles.index(name)] for name in opt[:-1]]
350            except ValueError:
351                continue
352            val = opt[-1](*args)
353
354        if val is not obj:
355            return val
356
357    optitems = [
358        "'" + opt + "'"
359            if isinstance(opt, str)
360            else ' & '.join(["'" + o + "'" for o in opt[:-1]])
361        for opt in name_options
362    ]
363    if len(optitems) == 1:
364        optsummary = optitems[0]
365    elif len(optitems) == 2:
366        optsummary = optitems[0] + " or " + optitems[1]
367    else:
368        optsummary = ', '.join(optitems[:-1]) + ", or " + optitems[-1]
369    raise ValueError(
370        "Roster does not have column(s) {}. Columns are:\n  {}".format(
371            optsummary,
372            '\n  '.join(titles)
373        )
374    )

Extracts info from a CSV row that might be found in different columns (or combinations of columns).

Needs a list of column titles (lowercased strings), plus a name options list, and the row to extract from. Each item in the name options list is either a string (column name in lowercase), or a tuple containing one or more strings followed by a function to be called with the values of those columns as arguments whose result will be used as the value.

Returns the value from the row as extracted by the extraction function, or simply looked up in the case of a string entry. Tries options from the first to the last and returns the value from the first one that worked.

def load_roster_from_stream(iterable_of_strings):
377def load_roster_from_stream(iterable_of_strings):
378    """
379    Implements the roster-loading logic given an iterable of strings,
380    like an open file or a list of strings. See `AsRoster`.
381
382    Each entry in the dictionary it returns has the following keys:
383
384    - 'username': The student's username
385    - 'fullname': The student's full name (as it appears in the roster
386        file, so not always what they want to go by).
387    - 'sortname': A version of their name with 'last' name first that is
388        used to sort students.
389    - 'course_section': Which section the student is in.
390    """
391    reader = csv.reader(iterable_of_strings)
392
393    students = {}
394    # [2018/09/16, lyn] Change to handle roster with titles
395    # [2019/09/13, Peter] Change to use standard Registrar roster columns
396    # by default
397    titles = next(reader) # Read first title line of roster
398    titles = [x.lower() for x in titles] # convert columns to lower-case
399
400    if "sort name" in titles:
401        sortnameIndex = titles.index('sort name')
402    elif "sortname" in titles:
403        sortnameIndex = titles.index('sortname')
404    else:
405        sortnameIndex = None
406
407    for row in reader:
408        username = get_variable_field_value(
409            titles,
410            row,
411            [
412                ('email', lambda e: e.split('@')[0]),
413                'username'
414            ]
415        )
416        if 0 < len(username):
417            name = get_variable_field_value(
418                titles,
419                row,
420                [
421                    'name', 'student name',
422                    ('first', 'last', lambda fi, la: fi + ' ' + la),
423                    'sort name'
424                ]
425            )
426            section = get_variable_field_value(
427                titles,
428                row,
429                [
430                    'section',
431                    'lecture section',
432                    'lec',
433                    'lec sec',
434                    'lec section',
435                    'course_title',
436                ]
437            )
438            namebits = name.split()
439            if sortnameIndex is not None:
440                sort_by = row[sortnameIndex]
441            else:
442                sort_by = ' '.join(
443                    [section, namebits[-1]]
444                    + namebits[:-1]
445                )
446            students[username] = {
447                'username': username,
448                'fullname': name,
449                'sortname': sort_by,
450                'course_section': section
451            }
452        pass
453    pass
454    return students

Implements the roster-loading logic given an iterable of strings, like an open file or a list of strings. See AsRoster.

Each entry in the dictionary it returns has the following keys:

  • 'username': The student's username
  • 'fullname': The student's full name (as it appears in the roster file, so not always what they want to go by).
  • 'sortname': A version of their name with 'last' name first that is used to sort students.
  • 'course_section': Which section the student is in.
def get_task_info(course, semester):
461def get_task_info(course, semester):
462    """
463    Loads the task info from the JSON file (or returns a cached version
464    if the file hasn't been modified since we last loaded it). Needs the
465    course and semester to load info for.
466
467    Returns None if the file doesn't exist or can't be parsed.
468
469    Pset and task URLs are added to the information loaded.
470    """
471    filename = task_info_file(course, semester)
472    try:
473        result = load_or_get_cached(
474            filename,
475            assume_fresh=_CONFIG.get("ASSUME_FRESH", 1)
476        )
477    except Exception:
478        flask.flash("Failed to read task info file!")
479        tb = potluck.html_tools.string_traceback()
480        print(
481            "Failed to read task info file:\n" + tb,
482            file=sys.stderr
483        )
484        result = None
485
486    if result is None:
487        return None
488
489    # Augment task info
490    prfmt = result.get(
491        "project_url_format",
492        _CONFIG.get("DEFAULT_PROJECT_URL_FORMAT", "#")
493    )
494    taskfmt = result.get(
495        "task_url_format",
496        _CONFIG.get("DEFAULT_TASK_URL_FORMAT", "#")
497    )
498    for project in result.get("projects", result.get("psets")):
499        project["url"] = prfmt.format(
500            semester=semester,
501            project=project["id"]
502        )
503        for task in project["tasks"]:
504            task["url"] = taskfmt.format(
505                semester=semester,
506                project=project["id"],
507                task=task["id"]
508            )
509            # Graft static task info into project task entry
510            task.update(result["tasks"][task["id"]])
511
512    # Augment exercise info if it's present
513    exfmt = result.get(
514        "exercise_url_format",
515        _CONFIG.get("DEFAULT_EXERCISE_URL_FORMAT", "#")
516    )
517    if 'exercises' in result:
518        for egroup in result["exercises"]:
519            if 'url' not in egroup:
520                egroup["url"] = exfmt.format(
521                    semester=semester,
522                    group=egroup["group"]
523                )
524
525    return result

Loads the task info from the JSON file (or returns a cached version if the file hasn't been modified since we last loaded it). Needs the course and semester to load info for.

Returns None if the file doesn't exist or can't be parsed.

Pset and task URLs are added to the information loaded.

def get_concepts(course, semester):
528def get_concepts(course, semester):
529    """
530    Loads concepts from the JSON file (or returns a cached version if the
531    file hasn't been modified since we last loaded it). Needs the course
532    and semester to load info for.
533
534    Returns None if the file doesn't exist or can't be parsed.
535    """
536    filename = concepts_file(course, semester)
537    try:
538        return load_or_get_cached(
539            filename,
540            assume_fresh=_CONFIG.get("ASSUME_FRESH", 1)
541        )
542    except Exception:
543        flask.flash("Failed to read concepts file!")
544        tb = potluck.html_tools.string_traceback()
545        print(
546            "Failed to read concepts file:\n" + tb,
547            file=sys.stderr
548        )
549        return None

Loads concepts from the JSON file (or returns a cached version if the file hasn't been modified since we last loaded it). Needs the course and semester to load info for.

Returns None if the file doesn't exist or can't be parsed.

def get_admin_info(course, semester):
552def get_admin_info(course, semester):
553    """
554    Reads the admin info file to get information about which users are
555    administrators and various other settings.
556    """
557    filename = admin_info_file(course, semester)
558    try:
559        result = load_or_get_cached(
560            filename,
561            assume_fresh=_CONFIG.get("ASSUME_FRESH", 1)
562        )
563    except Exception:
564        flask.flash("Failed to read admin info file '{}'!".format(filename))
565        tb = potluck.html_tools.string_traceback()
566        print(
567            "Failed to read admin info file:\n" + tb,
568            file=sys.stderr
569        )
570        result = None
571
572    return result # might be None

Reads the admin info file to get information about which users are administrators and various other settings.

def get_roster(course, semester):
575def get_roster(course, semester):
576    """
577    Loads and returns the roster file. Returns None if the file is
578    missing. Returns a dictionary where usernames are keys and values are
579    student info (see `AsRoster`).
580    """
581    return load_or_get_cached(
582        roster_file(course, semester),
583        view=AsRoster,
584        missing=None,
585        assume_fresh=_CONFIG.get("ASSUME_FRESH", 1)
586    )

Loads and returns the roster file. Returns None if the file is missing. Returns a dictionary where usernames are keys and values are student info (see AsRoster).

def get_student_info(course, semester):
589def get_student_info(course, semester):
590    """
591    Loads and returns the student info file. Returns None if the file is
592    missing.
593    """
594    return load_or_get_cached(
595        student_info_file(course, semester),
596        view=AsStudentInfo,
597        missing=None,
598        assume_fresh=_CONFIG.get("ASSUME_FRESH", 1)
599    )

Loads and returns the student info file. Returns None if the file is missing.

def get_extension(course, semester, username, project, phase):
602def get_extension(course, semester, username, project, phase):
603    """
604    Gets the extension value (as an integer number in hours) for a user
605    on a given phase of a given project. Returns 0 if there is no
606    extension info for that user. Returns None if there's an error
607    reading the value.
608    """
609    key = extension_key(course, semester, username, project, phase)
610    try:
611        result = _REDIS.get(key)
612    except Exception:
613        flask.flash(
614            "Failed to read extension info at '{}'!".format(key)
615        )
616        tb = potluck.html_tools.string_traceback()
617        print(
618            "Failed to read extension info at '{}':\n{}".format(
619                key,
620                tb
621            ),
622            file=sys.stderr
623        )
624        return None
625
626    if result is None:
627        result = 0
628    else:
629        result = int(result)
630
631    return result

Gets the extension value (as an integer number in hours) for a user on a given phase of a given project. Returns 0 if there is no extension info for that user. Returns None if there's an error reading the value.

def set_extension( course, semester, username, prid, phase, duration=True, only_from=None):
634def set_extension(
635    course,
636    semester,
637    username,
638    prid,
639    phase,
640    duration=True,
641    only_from=None
642):
643    """
644    Sets an extension value for the given user on the given phase of the
645    given project (in the given course/semester). May be an integer
646    number of hours, or just True (the default) for the standard
647    extension (whatever is listed in tasks.json). Set to False to remove
648    any previously granted extension.
649
650    If only_from is provided, the operation will fail when the extension
651    value being updated isn't set to that value (may be a number of
652    hours, or True for the standard extension, or False for unset). In
653    that case, this function will return False if it fails. Set
654    only_from to None to unconditionally update the extension.
655    """
656    key = extension_key(course, semester, username, prid, phase)
657    task_info = get_task_info(course, semester)
658    ext_hours = task_info.get("extension_hours", 24)
659
660    if duration is True:
661        duration = ext_hours
662    elif duration is False:
663        duration = 0
664    elif not isinstance(duration, (int, bool)):
665        raise ValueError(
666            (
667                "Extension duration must be an integer number of hours,"
668                " or a boolean (got {})."
669            ).format(repr(duration))
670        )
671
672    if only_from is True:
673        only_from = ext_hours
674    elif (
675        only_from not in (False, None)
676    and not isinstance(only_from, int)
677    ):
678        raise ValueError(
679            (
680                "Only-from must be None, a boolean, or an integer (got"
681                " {})."
682            ).format(repr(only_from))
683        )
684
685    with _REDIS.pipeline() as pipe:
686        # Make sure we back off if there's a WatchError
687        try:
688            pipe.watch(key)
689            # Check current value
690            current = _REDIS.get(key)
691            if current is not None:
692                current = int(current) # convert from string
693
694            if duration == current:
695                # No need to update!
696                return True
697
698            if only_from is not None and (
699                (only_from is False and current not in (None, 0))
700             or (only_from is not False and current != only_from)
701            ):
702                # Abort operation because of pre-op change
703                flask.flash(
704                    (
705                        "Failed to write extension info at '{}' (slow"
706                        " change)!"
707                    ).format(key)
708                )
709                return False
710
711            # Go ahead and update the value
712            pipe.multi()
713            pipe.set(key, str(duration))
714            pipe.execute()
715        except redis.exceptions.WatchError:
716            # Update didn't go through
717            flask.flash(
718                (
719                    "Failed to write extension info at '{}' (fast"
720                    " change)!"
721                ).format(key)
722            )
723            return False
724        except Exception:
725            # Some other issue
726            flask.flash(
727                (
728                    "Failed to write extension info at '{}' (unknown)!"
729                ).format(key)
730            )
731            tb = potluck.html_tools.string_traceback()
732            print(
733                "Failed to write extension info at '{}':\n{}".format(
734                    key,
735                    tb
736                ),
737                file=sys.stderr
738            )
739            return False
740
741    return True

Sets an extension value for the given user on the given phase of the given project (in the given course/semester). May be an integer number of hours, or just True (the default) for the standard extension (whatever is listed in tasks.json). Set to False to remove any previously granted extension.

If only_from is provided, the operation will fail when the extension value being updated isn't set to that value (may be a number of hours, or True for the standard extension, or False for unset). In that case, this function will return False if it fails. Set only_from to None to unconditionally update the extension.

def get_inflight(course, semester, username, phase, prid, taskid):
744def get_inflight(
745    course,
746    semester,
747    username,
748    phase,
749    prid,
750    taskid
751):
752    """
753    Returns a quadruple containing the timestamp at which processing for
754    the given user/phase/project/task was started, the filename of the log
755    file for that evaluation run, the filename of the report file that
756    will be generated when it's done, and a string indicating the status
757    of the run. Reads that log file to check whether the process has
758    completed, and updates in-flight state accordingly. Returns (None,
759    None, None, None) if no attempts to grade the given task have been
760    made yet.
761
762    The status string will be one of:
763
764    - "initial" - evaluation hasn't started yet.
765    - "in_progress" - evaluation is running.
766    - "error" - evaluation noted an error in the log.
767    - "expired" - We didn't hear back from evaluation, but it's been so
768         long that we've given up hope.
769    - "completed" - evaluation finished.
770
771    When status is "error", "expired", or "completed", it's appropriate
772    to initiate a new evaluation run for that file, but in other cases,
773    the existing run should be allowed to terminate first.
774
775    In rare cases, when an exception is encountered trying to read the
776    file even after a second attempt, the timestamp will be set to
777    "error" with status and filename values of None.
778    """
779    key = inflight_key(course, semester, username, phase, prid, taskid)
780
781    try:
782        response = _REDIS.lrange(key, 0, -1)
783    except Exception:
784        flask.flash(
785            "Failed to fetch in-flight info at '{}'!".format(key)
786        )
787        tb = potluck.html_tools.string_traceback()
788        print(
789            "Failed to fetch in-flight info at '{}':\n{}".format(
790                key,
791                tb
792            ),
793            file=sys.stderr
794        )
795        return ("error", None, None, None)
796
797    # If the key didn't exist
798    if response is None or len(response) == 0:
799        return (None, None, None, None)
800
801    # Unpack the response
802    timestring, log_filename, report_filename, status = response
803
804    if status in ("error", "expired", "completed"):
805        # No need to check the log text again
806        return (timestring, log_filename, report_filename, status)
807
808    # Figure out what the new status should be...
809    new_status = status
810
811    # Read the log file to see if evaluation has finished yet
812    if os.path.isfile(log_filename):
813        try:
814            with open(log_filename, 'r') as fin:
815                log_text = fin.read()
816        except Exception:
817            flask.flash("Failed to read evaluation log file!")
818            tb = potluck.html_tools.string_traceback()
819            print(
820                "Failed to read evaluation log file:\n" + tb,
821                file=sys.stderr
822            )
823            # Treat as a missing file
824            log_text = ""
825    else:
826        # No log file
827        log_text = ""
828
829    # If anything has been written to the log file, we're in progress...
830    if status == "initial" and log_text != "":
831        new_status = "in_progress"
832
833    # Check for an error
834    if potluck.render.ERROR_MSG in log_text:
835        new_status = "error"
836
837    # Check for completion message (ignored if there's an error)
838    if (
839        status in ("initial", "in_progress")
840    and new_status != "error"
841    and log_text.endswith(potluck.render.DONE_MSG + '\n')
842    ):
843        new_status = "completed"
844
845    # Check absolute timeout (only if we DIDN'T see a done message)
846    if new_status not in ("error", "completed"):
847        elapsed = (
848            potluck.time_utils.now()
849          - potluck.time_utils.time_from_timestring(timestring)
850        )
851        allowed = datetime.timedelta(
852            seconds=_CONFIG["FINAL_EVAL_TIMEOUT"]
853        )
854        if elapsed > allowed:
855            new_status = "expired"
856
857    # Now we've got our result
858    result = (timestring, log_filename, report_filename, new_status)
859
860    # Write new status if it has changed
861    if new_status != status:
862        try:
863            with _REDIS.pipeline() as pipe:
864                pipe.delete(key) # clear the list
865                pipe.rpush(key, *result) # add our new info
866                pipe.execute()
867        except Exception:
868            flask.flash(
869                (
870                    "Error trying to update in-flight info at '{}'."
871                ).format(key)
872            )
873            tb = potluck.html_tools.string_traceback()
874            print(
875                "Failed to update in-flight info at '{}':\n{}".format(
876                    key,
877                    tb
878                ),
879                file=sys.stderr
880            )
881            return ("error", None, None, None)
882
883    # Return our result
884    return result

Returns a quadruple containing the timestamp at which processing for the given user/phase/project/task was started, the filename of the log file for that evaluation run, the filename of the report file that will be generated when it's done, and a string indicating the status of the run. Reads that log file to check whether the process has completed, and updates in-flight state accordingly. Returns (None, None, None, None) if no attempts to grade the given task have been made yet.

The status string will be one of:

  • "initial" - evaluation hasn't started yet.
  • "in_progress" - evaluation is running.
  • "error" - evaluation noted an error in the log.
  • "expired" - We didn't hear back from evaluation, but it's been so long that we've given up hope.
  • "completed" - evaluation finished.

When status is "error", "expired", or "completed", it's appropriate to initiate a new evaluation run for that file, but in other cases, the existing run should be allowed to terminate first.

In rare cases, when an exception is encountered trying to read the file even after a second attempt, the timestamp will be set to "error" with status and filename values of None.

def put_inflight(course, semester, username, phase, prid, taskid):
887def put_inflight(course, semester, username, phase, prid, taskid):
888    """
889    Picks new log and report filenames for the given
890    user/phase/project/task and returns a quad containing a string
891    timestamp, the new log filename, the new report filename, and the
892    status string "initial", while also writing that information into
893    the inflight data for that user so that get_inflight will return it
894    until evaluation is finished.
895
896    Returns (None, None, None, None) if there is already an in-flight
897    log file for this user/project/task that has a status other than
898    "error", "expired", or "completed".
899
900    Returns ("error", None, None, None) if it encounters a situation
901    where the inflight key is changed during the update operation,
902    presumably by another simultaneous call to put_inflight. This
903    ensures that only one simultaneous call can succeed, and protects
904    against race conditions on the log and report filenames.
905    """
906    # The Redis key for inflight info
907    key = inflight_key(course, semester, username, phase, prid, taskid)
908
909    with _REDIS.pipeline() as pipe:
910        try:
911            pipe.watch(key)
912            response = pipe.lrange(key, 0, -1)
913            if response is not None and len(response) != 0:
914                # key already exists, so we need to check status
915                prev_ts, prev_log, prev_result, prev_status = response
916                if prev_status in ("initial", "in_progress"):
917                    # Another evaluation is in-flight; indicate that to
918                    # our caller and refuse to re-initiate evaluation
919                    return (None, None, None, None)
920
921            # Generate a timestamp for the log file
922            timestamp = potluck.time_utils.timestring()
923
924            # Get unused log and report filenames
925            istring = "{phase}-{prid}-{taskid}-{timestamp}".format(
926                phase=phase,
927                prid=prid,
928                taskid=taskid,
929                timestamp=timestamp
930            )
931
932            # Note: unused_filename has a race condition if two
933            # put_inflight calls occur simultaneously. However, due to
934            # our use of watch, only one of the two calls can make it
935            # out of this block without triggering a WatchError, meaning
936            # that only the one that makes it out first will make use of
937            # a potentially-conflicting filename. That said, *any* other
938            # process which might create files with names like the log
939            # and report filenames we select would be bad.
940
941            # Select an unused log filename
942            log_folder = logs_folder(course, semester, username)
943            ensure_directory(log_folder)
944            logfile = unused_filename(
945                safe_join(log_folder, istring + ".log")
946            )
947
948            # Select an unused report filename
949            report_folder = reports_folder(course, semester, username)
950            ensure_directory(report_folder)
951            reportfile = unused_filename(
952                safe_join(report_folder, istring + ".json")
953            )
954
955            # Gather the info into a tuple
956            ifinfo = (
957                timestamp,
958                logfile,
959                reportfile,
960                "initial"
961            )
962
963            # Rewrite the key
964            pipe.multi()
965            pipe.delete(key)
966            pipe.rpush(key, *ifinfo)
967            pipe.execute()
968        except redis.exceptions.WatchError:
969            flask.flash(
970                (
971                    "Unable to put task evaluation in-flight: key '{}'"
972                    " was changed."
973                ).format(key)
974            )
975            return ("error", None, None, None)
976        except Exception:
977            flask.flash(
978                (
979                    "Error trying to write in-flight info at '{}'."
980                ).format(key)
981            )
982            tb = potluck.html_tools.string_traceback()
983            print(
984                "Failed to write in-flight info at '{}':\n{}".format(
985                    key,
986                    tb
987                ),
988                file=sys.stderr
989            )
990            return ("error", None, None, None)
991
992    # Return the timestamp, filenames, and status that we recorded
993    return ifinfo

Picks new log and report filenames for the given user/phase/project/task and returns a quad containing a string timestamp, the new log filename, the new report filename, and the status string "initial", while also writing that information into the inflight data for that user so that get_inflight will return it until evaluation is finished.

Returns (None, None, None, None) if there is already an in-flight log file for this user/project/task that has a status other than "error", "expired", or "completed".

Returns ("error", None, None, None) if it encounters a situation where the inflight key is changed during the update operation, presumably by another simultaneous call to put_inflight. This ensures that only one simultaneous call can succeed, and protects against race conditions on the log and report filenames.

def fetch_time_spent(course, semester, username, phase, prid, taskid):
 996def fetch_time_spent(course, semester, username, phase, prid, taskid):
 997    """
 998    Returns a time-spent record for the given user/phase/project/task.
 999    It has the following keys:
1000
1001    - "phase": The phase (a string).
1002    - "prid": The project ID (a string).
1003    - "taskid": The task ID (a string).
1004    - "updated_at": A timestring (see `potluck.time_utils.timestring`)
1005        indicating when the information was last updated.
1006    - "time_spent": A floating-point number (as a string) or just a
1007        string describing the user's description of the time they spent
1008        on the task.
1009    - "prev_update": If present, indicates that the time_spent value
1010        came from a previous entry and was preserved when a newer entry
1011        would have been empty. Shows the time at which the previous
1012        entry was entered.
1013        TODO: preserve across multiple empty entries?
1014
1015    Returns None if there is no information for that user/project/task
1016    yet, or if an error is encountered while trying to access that
1017    information.
1018    """
1019    # Redis key to use
1020    key = time_spent_key(
1021        course,
1022        semester,
1023        username,
1024        prid,
1025        phase,
1026        taskid
1027    )
1028
1029    try:
1030        response = _REDIS.hmget(
1031            key,
1032            "updated_at",
1033            "time_spent",
1034            "prev_update"
1035        )
1036    except Exception:
1037        flask.flash("Error fetching time-spent info.")
1038        tb = potluck.html_tools.string_traceback()
1039        print(
1040            "Failed to fetch time spent info at '{}':\n{}".format(
1041                key,
1042                tb
1043            ),
1044            file=sys.stderr
1045        )
1046        return None
1047
1048    # Some kind of non-exception error during access, or key is missing
1049    if response is None or len(response) != 3 or response[0] is None:
1050        return None
1051
1052    try:
1053        spent = float(response[1])
1054    except ValueError:
1055        spent = response[1]
1056
1057    result = {
1058        "phase": phase,
1059        "prid": prid,
1060        "taskid": taskid,
1061        "updated_at": response[0],
1062        "time_spent": spent
1063    }
1064
1065    if response[2] is not None:
1066        result["prev_update"] = response[2]
1067
1068    return result

Returns a time-spent record for the given user/phase/project/task. It has the following keys:

  • "phase": The phase (a string).
  • "prid": The project ID (a string).
  • "taskid": The task ID (a string).
  • "updated_at": A timestring (see potluck.time_utils.timestring) indicating when the information was last updated.
  • "time_spent": A floating-point number (as a string) or just a string describing the user's description of the time they spent on the task.
  • "prev_update": If present, indicates that the time_spent value came from a previous entry and was preserved when a newer entry would have been empty. Shows the time at which the previous entry was entered. TODO: preserve across multiple empty entries?

Returns None if there is no information for that user/project/task yet, or if an error is encountered while trying to access that information.

def record_time_spent(course, semester, username, phase, prid, taskid, time_spent):
1071def record_time_spent(
1072    course,
1073    semester,
1074    username,
1075    phase,
1076    prid,
1077    taskid,
1078    time_spent
1079):
1080    """
1081    Inserts a time spent entry into the given user's time spent info.
1082
1083    If called multiple times, the last call will override the
1084    information set by any previous ones. If called multiple times
1085    simultaneously, one of the calls will overwrite the other, but it
1086    may not be able to pull the other call's info to replace a default
1087    value (which is fine...).
1088    """
1089    # Redis key to use
1090    key = time_spent_key(
1091        course,
1092        semester,
1093        username,
1094        prid,
1095        phase,
1096        taskid
1097    )
1098
1099    # Generate a timestamp for the info
1100    timestring = potluck.time_utils.timestring()
1101
1102    # Convert to a number if we can
1103    try:
1104        time_spent = float(time_spent)
1105    except Exception:
1106        pass
1107
1108    # Here's the info we store
1109    info = {
1110        "updated_at": timestring,
1111        "time_spent": time_spent
1112    }
1113
1114    # Check for old info if the new info is missing
1115    if time_spent == "":
1116        try:
1117            response = _REDIS.hmget(
1118                key,
1119                "updated_at",
1120                "time_spent",
1121                "prev_update"
1122            )
1123            if (
1124                response is None
1125             or len(response) != 2
1126            ):
1127                raise ValueError(
1128                    "Unable to retrieve previous data from time spent"
1129                    " info."
1130                )
1131            # check for missing key, or no previous info
1132            if response[0] is not None and response[1] != '':
1133                if response[2] is None:
1134                    prev = response[0]
1135                else:
1136                    prev = response[2]
1137
1138                info["prev_update"] = prev
1139                info["time_spent"] = response[1]
1140            # else leave info as-is
1141
1142        except Exception:
1143            flask.flash("Failed to fetch time spent info!")
1144            tb = potluck.html_tools.string_traceback()
1145            print(
1146                "Failed to fetch time spent info at '{}':\n{}".format(
1147                    key,
1148                    tb
1149                ),
1150                file=sys.stderr
1151            )
1152            # we'll keep going to update new info though
1153
1154    try:
1155        success = _REDIS.hmset(key, info)
1156        if success is not True:
1157            raise ValueError("Redis result indicated failure.")
1158    except Exception:
1159        flask.flash("Failed to write time-spent info!")
1160        tb = potluck.html_tools.string_traceback()
1161        print(
1162            "Failed to write time spent info at '{}':\n{}".format(
1163                key,
1164                tb
1165            ),
1166            file=sys.stderr
1167        )

Inserts a time spent entry into the given user's time spent info.

If called multiple times, the last call will override the information set by any previous ones. If called multiple times simultaneously, one of the calls will overwrite the other, but it may not be able to pull the other call's info to replace a default value (which is fine...).

def fetch_evaluation(course, semester, username, phase, prid, taskid):
1170def fetch_evaluation(course, semester, username, phase, prid, taskid):
1171    """
1172    Fetches the manual evaluation information for the given
1173    user/phase/project/task. The result will be a dictionary with the
1174    following keys:
1175
1176    - "phase": The phase (a string).
1177    - "prid": The project ID (a string).
1178    - "taskid": The task ID (a string).
1179    - "updated_at": A timestring (see `potluck.time_utils.timestring`)
1180        indicating when the information was last updated.
1181    - "notes": The markdown source string for custom notes.
1182    - "override": A numerical score that overrides the automatic
1183        evaluation. Will be an empty string if there is no override to
1184        apply.
1185    - "timeliness": A numerical score for timeliness points to override
1186        the automatic value. Will be an empty string if there is no
1187        override, which should always be the case for non-initial phases.
1188
1189    Returns None instead of a dictionary if there is no information for
1190    that user/project/task yet, or if an error is encountered while
1191    trying to access that information.
1192    """
1193    # Redis key to use
1194    key = evaluation_key(
1195        course,
1196        semester,
1197        username,
1198        prid,
1199        phase,
1200        taskid
1201    )
1202
1203    try:
1204        response = _REDIS.hmget(
1205            key,
1206            "updated_at",
1207            "notes",
1208            "override",
1209            "timeliness"
1210        )
1211    except Exception:
1212        flask.flash("Error fetching evaluation info.")
1213        tb = potluck.html_tools.string_traceback()
1214        print(
1215            "Failed to fetch evaluation info at '{}':\n{}".format(
1216                key,
1217                tb
1218            ),
1219            file=sys.stderr
1220        )
1221        return None
1222
1223    # Some kind of non-exception error during access, or key is missing
1224    if (
1225        response is None
1226     or len(response) != 4
1227     or response[0] is None
1228    ):
1229        return None
1230
1231    try:
1232        override = float(response[2])
1233    except (TypeError, ValueError):
1234        override = response[2] or ''
1235
1236    try:
1237        timeliness = float(response[3])
1238    except (TypeError, ValueError):
1239        timeliness = response[3] or ''
1240
1241    result = {
1242        "phase": phase,
1243        "prid": prid,
1244        "taskid": taskid,
1245        "updated_at": response[0],
1246        "notes": response[1],
1247        "override": override,
1248        "timeliness": timeliness,
1249    }
1250
1251    return result

Fetches the manual evaluation information for the given user/phase/project/task. The result will be a dictionary with the following keys:

  • "phase": The phase (a string).
  • "prid": The project ID (a string).
  • "taskid": The task ID (a string).
  • "updated_at": A timestring (see potluck.time_utils.timestring) indicating when the information was last updated.
  • "notes": The markdown source string for custom notes.
  • "override": A numerical score that overrides the automatic evaluation. Will be an empty string if there is no override to apply.
  • "timeliness": A numerical score for timeliness points to override the automatic value. Will be an empty string if there is no override, which should always be the case for non-initial phases.

Returns None instead of a dictionary if there is no information for that user/project/task yet, or if an error is encountered while trying to access that information.

def set_evaluation( course, semester, username, phase, prid, taskid, notes, override='', timeliness=''):
1254def set_evaluation(
1255    course,
1256    semester,
1257    username,
1258    phase,
1259    prid,
1260    taskid,
1261    notes,
1262    override="",
1263    timeliness=""
1264):
1265    """
1266    Updates the custom evaluation info for a particular task submitted by
1267    a particular user for a certain phase of a specific project (in a
1268    course/semester).
1269
1270    Completely erases the previous custom evaluation info.
1271
1272    The notes argument must be a string, and will be treated as Markdown
1273    and converted to HTML when being displayed to the user. It will be
1274    displayed on a feedback page and it can thus link to rubric items or
1275    snippets by their IDs if you want to get fancy.
1276
1277    The override argument defaults to an empty string, which is how to
1278    indicate that no override should be applied. Otherwise, it should be
1279    a floating-point number or integer between 0 and 100; it will be
1280    stored as a float if convertible.
1281
1282    The timeliness argument works like the override argument, but
1283    overrides the timeliness score. It should only be set for the
1284    'initial' phase.
1285
1286    Returns True if it succeeds or False if it encounters some sort of
1287    error.
1288    """
1289    # Redis key to use
1290    key = evaluation_key(
1291        course,
1292        semester,
1293        username,
1294        prid,
1295        phase,
1296        taskid
1297    )
1298
1299    # Get task info for this course/semester so we can access per-course
1300    # config values...
1301    task_info = get_task_info(course, semester)
1302    relevant_task = task_info.get("tasks", {}).get(taskid, {})
1303    relevant_projects = [
1304        p
1305        for p in task_info.get("projects", task_info.get("psets", {}))
1306        if p["id"] == prid
1307    ]
1308    if len(relevant_projects) > 0:
1309        relevant_project = relevant_projects[0]
1310    else:
1311        relevant_project = {}
1312
1313    # Generate a timestamp for the info
1314    timestring = potluck.time_utils.timestring()
1315
1316    # Get SCORE_BASIS and TIMELINESS_POINTS values from
1317    # task/project/task_info/config
1318    score_basis = relevant_task.get(
1319        "SCORE_BASIS",
1320        relevant_project.get(
1321            "SCORE_BASIS",
1322            task_info.get(
1323                "SCORE_BASIS",
1324                _CONFIG.get("SCORE_BASIS", 100)
1325            )
1326        )
1327    )
1328    timeliness_basis = relevant_task.get(
1329        "TIMELINESS_POINTS",
1330        relevant_project.get(
1331            "TIMELINESS_POINTS",
1332            task_info.get(
1333                "TIMELINESS_POINTS",
1334                _CONFIG.get("TIMELINESS_POINTS", 10)
1335            )
1336        )
1337    )
1338
1339    # Convert to a number if we can
1340    if override != "":
1341        try:
1342            override = float(override)
1343            if 0 < override < 1 and score_basis >= 10:
1344                flask.flash(
1345                    (
1346                        "Warning: you entered '{}' as the grade"
1347                        " override, but scores should be specified out"
1348                        " of {}, not out of 1! The override has been"
1349                        " set as-given but you may want to update it."
1350                    ).format(override, score_basis)
1351                )
1352        except Exception:
1353            flask.flash(
1354                (
1355                    "Warning: you entered '{}' as the grade override,"
1356                    " but grade overrides should be numbers between 0"
1357                    " and {}. The override has been set as-given, but"
1358                    " you may want to update it."
1359                ).format(override, score_basis)
1360            )
1361
1362    # Convert to a number if we can
1363    if timeliness != "":
1364        try:
1365            timeliness = float(timeliness)
1366            if 0 < timeliness < 1 and timeliness_basis >= 5:
1367                flask.flash(
1368                    (
1369                        "Warning: you entered '{}' as the timeliness"
1370                        " override, but timeliness scores should be"
1371                        " specified out of {}, not out of 1! The"
1372                        " override has been set as-given but you may"
1373                        " want to update it."
1374                    ).format(timeliness, timeliness_basis)
1375                )
1376        except Exception:
1377            flask.flash(
1378                (
1379                    "Warning: you entered '{}' as the grade override,"
1380                    " but timeliness overrides should be numbers"
1381                    " between 0 and {}. The override has been set"
1382                    " as-given, but you may want to update it."
1383                ).format(override, timeliness_basis)
1384            )
1385
1386    # Here's the info we store
1387    info = {
1388        "updated_at": timestring,
1389        "notes": notes,
1390        "override": override,
1391        "timeliness": timeliness,
1392    }
1393
1394    try:
1395        success = _REDIS.hmset(key, info)
1396        if success is not True:
1397            raise ValueError("Redis result indicated failure.")
1398    except Exception:
1399        flask.flash("Failed to write evaluation info!")
1400        tb = potluck.html_tools.string_traceback()
1401        print(
1402            "Failed to write evaluation info at '{}':\n{}".format(
1403                key,
1404                tb
1405            ),
1406            file=sys.stderr
1407        )
1408        return False
1409
1410    return True

Updates the custom evaluation info for a particular task submitted by a particular user for a certain phase of a specific project (in a course/semester).

Completely erases the previous custom evaluation info.

The notes argument must be a string, and will be treated as Markdown and converted to HTML when being displayed to the user. It will be displayed on a feedback page and it can thus link to rubric items or snippets by their IDs if you want to get fancy.

The override argument defaults to an empty string, which is how to indicate that no override should be applied. Otherwise, it should be a floating-point number or integer between 0 and 100; it will be stored as a float if convertible.

The timeliness argument works like the override argument, but overrides the timeliness score. It should only be set for the 'initial' phase.

Returns True if it succeeds or False if it encounters some sort of error.

def get_egroup_override(course, semester, username, egroup):
1413def get_egroup_override(
1414    course,
1415    semester,
1416    username,
1417    egroup
1418):
1419    """
1420    Returns the score override for a particular exercise group. The
1421    result is a dictionary with the following keys:
1422
1423    - "updated_at": A timestring indicating when the override was set.
1424    - "status": The status string specified by the override.
1425    - "note": A string specified by the person who set the override.
1426    - "override": The grade override, as a floating-point value based on
1427        the exercise group's SCORE_BASIS, or an empty string if there is
1428        no override.
1429
1430    In case of an error or when no override is present, the result will
1431    be `None`.
1432    """
1433    # Redis key to use
1434    key = egroup_override_key(
1435        course,
1436        semester,
1437        username,
1438        egroup
1439    )
1440
1441    try:
1442        response = _REDIS.hmget(
1443            key,
1444            "updated_at",
1445            "status",
1446            "note",
1447            "override"
1448        )
1449    except Exception:
1450        flask.flash(
1451            (
1452                "Error fetching exercise group override info for group"
1453                " '{}'."
1454            ).format(egroup)
1455        )
1456        tb = potluck.html_tools.string_traceback()
1457        print(
1458            "Failed to fetch exercise group override at '{}':\n{}".format(
1459                key,
1460                tb
1461            ),
1462            file=sys.stderr
1463        )
1464        return None
1465
1466    if response is None or len(response) != 4:
1467        return None
1468
1469    try:
1470        score = float(response[3])
1471    except (TypeError, ValueError):
1472        score = ''
1473
1474    return {
1475        "updated_at": response[0],
1476        "status": response[1],
1477        "note": response[2],
1478        "override": score
1479    }

Returns the score override for a particular exercise group. The result is a dictionary with the following keys:

  • "updated_at": A timestring indicating when the override was set.
  • "status": The status string specified by the override.
  • "note": A string specified by the person who set the override.
  • "override": The grade override, as a floating-point value based on the exercise group's SCORE_BASIS, or an empty string if there is no override.

In case of an error or when no override is present, the result will be None.

def set_egroup_override(course, semester, username, egroup, override='', note='', status=''):
1482def set_egroup_override(
1483    course,
1484    semester,
1485    username,
1486    egroup,
1487    override="",
1488    note="",
1489    status=""
1490):
1491    """
1492    Updates the exercise group score override for a particular exercise
1493    group submitted by a particular user for a specific exercise group
1494    (in a course/semester).
1495
1496    Completely erases the previous override info.
1497
1498    The `note` argument must be a string, and will be treated as Markdown
1499    and converted to HTML when being displayed to the user. It will be
1500    displayed on the student's dashboard in the expanded view for the
1501    exercise group.
1502
1503    The `override` argument defaults to an empty string, which is how to
1504    indicate that no override should be applied. Otherwise, it should be
1505    a floating-point number or integer between 0 and 1 (inclusive),
1506    indicating the fraction of full credit to award. It will be stored as
1507    a floating-point number if it's convertible to one. Note that this
1508    fraction is still subject to the `EXERCISE_GROUP_CREDIT_BUMP` logic
1509    in `app.ex_combined_grade`.
1510
1511    The `status` argument defaults to an empty string; in that case the
1512    status will not be changed. If not empty, it should be one of the
1513    strings "perfect," "complete," "partial," "incomplete," "pending,"
1514    or "unreleased."
1515
1516    Returns True if it succeeds or False if it encounters some sort of
1517    error.
1518    """
1519    # Redis key to use
1520    key = egroup_override_key(
1521        course,
1522        semester,
1523        username,
1524        egroup
1525    )
1526
1527    # Get task info for this course/semester so we can access per-course
1528    # config values...
1529    task_info = get_task_info(course, semester)
1530    all_egroups = task_info.get("exercises", [])
1531    this_eginfo = None
1532    for gr in all_egroups:
1533        if gr.get('group', '') == egroup:
1534            if this_eginfo is None:
1535                this_eginfo = gr
1536            else:
1537                flask.flash(
1538                    "Multiple exercise groups with group ID '{}'".format(
1539                        egroup
1540                    )
1541                )
1542                print(
1543                    "Multiple exercise groups with group ID '{}'".format(
1544                        egroup
1545                    ),
1546                    file=sys.stderr
1547                )
1548                # We keep using the first-specified group info
1549
1550    # No info for this egroup?
1551    if this_eginfo is None:
1552        flask.flash("No exercise group with group ID '{}'".format(egroup))
1553        print(
1554            "No exercise group with group ID '{}'".format(egroup),
1555            file=sys.stderr
1556        )
1557        return False
1558
1559    # Generate a timestamp for the info
1560    timestring = potluck.time_utils.timestring()
1561
1562    # Convert to a number if we can
1563    if override != "":
1564        try:
1565            override = float(override)
1566            if override > 1 or override < 0:
1567                flask.flash(
1568                    (
1569                        "Warning: you entered '{}' as the grade"
1570                        " override, but scores should be specified as"
1571                        " a fraction between 0.0 and 1.0. the override"
1572                        " has been set as-given but you may want to"
1573                        " update it."
1574                    ).format(override)
1575                )
1576        except Exception:
1577            flask.flash(
1578                (
1579                    "Warning: you entered '{}' as the grade override,"
1580                    " but grade overrides should be numbers."
1581                    " The override has been set as-given, but"
1582                    " you may want to update it."
1583                ).format(override)
1584            )
1585
1586    # Here's the info we store
1587    info = {
1588        "updated_at": timestring,
1589        "status": status,
1590        "note": note,
1591        "override": override
1592    }
1593
1594    try:
1595        success = _REDIS.hmset(key, info)
1596        if success is not True:
1597            raise ValueError("Redis result indicated failure.")
1598    except Exception:
1599        flask.flash("Failed to write evaluation info!")
1600        tb = potluck.html_tools.string_traceback()
1601        print(
1602            "Failed to write evaluation info at '{}':\n{}".format(
1603                key,
1604                tb
1605            ),
1606            file=sys.stderr
1607        )
1608        return False
1609
1610    return True

Updates the exercise group score override for a particular exercise group submitted by a particular user for a specific exercise group (in a course/semester).

Completely erases the previous override info.

The note argument must be a string, and will be treated as Markdown and converted to HTML when being displayed to the user. It will be displayed on the student's dashboard in the expanded view for the exercise group.

The override argument defaults to an empty string, which is how to indicate that no override should be applied. Otherwise, it should be a floating-point number or integer between 0 and 1 (inclusive), indicating the fraction of full credit to award. It will be stored as a floating-point number if it's convertible to one. Note that this fraction is still subject to the EXERCISE_GROUP_CREDIT_BUMP logic in app.ex_combined_grade.

The status argument defaults to an empty string; in that case the status will not be changed. If not empty, it should be one of the strings "perfect," "complete," "partial," "incomplete," "pending," or "unreleased."

Returns True if it succeeds or False if it encounters some sort of error.

def fetch_old_outcomes(course, semester, username, exercise):
1613def fetch_old_outcomes(course, semester, username, exercise):
1614    """
1615    Fetches old outcomes for the given course/semester/username/exercise.
1616    """
1617    # Redis key to use
1618    key = old_exercise_key(course, semester, username, exercise)
1619
1620    try:
1621        exists = _REDIS.exists(key)
1622    except Exception:
1623        flask.flash("Error checking for outcomes info.")
1624        tb = potluck.html_tools.string_traceback()
1625        print(
1626            "Failed to check for outcomes info at '{}':\n{}".format(
1627                key,
1628                tb
1629            ),
1630            file=sys.stderr
1631        )
1632        return None
1633
1634    # Return None without making a fuss if the key just doesn't exist
1635    if not exists:
1636        return None
1637
1638    try:
1639        responseJSON = _REDIS.get(key)
1640        info = json.loads(responseJSON)
1641    except Exception:
1642        flask.flash("Error fetching or decoding outcomes info.")
1643        tb = potluck.html_tools.string_traceback()
1644        print(
1645            "Failed to fetch outcomes info at '{}':\n{}".format(
1646                key,
1647                tb
1648            ),
1649            file=sys.stderr
1650        )
1651        return None
1652
1653    return info

Fetches old outcomes for the given course/semester/username/exercise.

def fetch_outcomes(course, semester, username, exercise, category):
1656def fetch_outcomes(course, semester, username, exercise, category):
1657    """
1658    Fetches the outcomes-list information for the given
1659    user/course/semester/exercise/category. The 'category' should be one
1660    of the strings 'full', 'partial', or 'none'. The result will be a
1661    list of dictionaries each representing a single submission, in
1662    chronological order. Each will have the following keys:
1663
1664    - "submitted_at" - A time string (see
1665        `potluck.time_utils.timestring`) indicating when the list of
1666        outcomes was submitted.
1667    - "authors" - A list of usernames for participating authors.
1668    - "outcomes" - A list of 3-tuple outcomes, which contain a boolean
1669        for success/failure followed by tag and message strings (see
1670        `optimism.listOutcomesInSuite`)
1671    - "code": A list of filename, code pairs with any code blocks
1672        submitted along with the outcomes.
1673    - "status": A status string indicating the overall exercise status,
1674        determined by callers to `save_outcomes`.
1675    - "credit": A number indicating how much credit (0-1) was earned for
1676        this outcome. May also be `None` in some cases.
1677    - "group_credit": A credit number that determines how much credit is
1678        earned towards this exercise group. Will be 0 when "credit" is
1679        `None`. If the category is 'none', this will always be 0, if the
1680        category is 'partial' it will be greater than 0 and less than 1,
1681        and if the category is 'full', it will be 1. This number does NOT
1682        account for timeliness (yet; see `fetch_best_outcomes`).
1683
1684    Returns None instead of a list if there is no information for that
1685    user/exercise/category yet, or if an error is encountered while
1686    trying to access that information.
1687    """
1688    # Redis key to use
1689    key = exercise_key(course, semester, username, exercise, category)
1690
1691    try:
1692        exists = _REDIS.exists(key)
1693    except Exception:
1694        flask.flash("Error checking for outcomes info.")
1695        tb = potluck.html_tools.string_traceback()
1696        print(
1697            "Failed to check for outcomes info at '{}':\n{}".format(
1698                key,
1699                tb
1700            ),
1701            file=sys.stderr
1702        )
1703        return None
1704
1705    # Return None without making a fuss if the key just doesn't exist
1706    if not exists:
1707        return None
1708
1709    try:
1710        responseJSON = _REDIS.get(key)
1711        info = json.loads(responseJSON)
1712    except Exception:
1713        flask.flash("Error fetching or decoding outcomes info.")
1714        tb = potluck.html_tools.string_traceback()
1715        print(
1716            "Failed to fetch outcomes info at '{}':\n{}".format(
1717                key,
1718                tb
1719            ),
1720            file=sys.stderr
1721        )
1722        return None
1723
1724    return info

Fetches the outcomes-list information for the given user/course/semester/exercise/category. The 'category' should be one of the strings 'full', 'partial', or 'none'. The result will be a list of dictionaries each representing a single submission, in chronological order. Each will have the following keys:

  • "submitted_at" - A time string (see potluck.time_utils.timestring) indicating when the list of outcomes was submitted.
  • "authors" - A list of usernames for participating authors.
  • "outcomes" - A list of 3-tuple outcomes, which contain a boolean for success/failure followed by tag and message strings (see optimism.listOutcomesInSuite)
  • "code": A list of filename, code pairs with any code blocks submitted along with the outcomes.
  • "status": A status string indicating the overall exercise status, determined by callers to save_outcomes.
  • "credit": A number indicating how much credit (0-1) was earned for this outcome. May also be None in some cases.
  • "group_credit": A credit number that determines how much credit is earned towards this exercise group. Will be 0 when "credit" is None. If the category is 'none', this will always be 0, if the category is 'partial' it will be greater than 0 and less than 1, and if the category is 'full', it will be 1. This number does NOT account for timeliness (yet; see fetch_best_outcomes).

Returns None instead of a list if there is no information for that user/exercise/category yet, or if an error is encountered while trying to access that information.

def update_submission_credit(submission, deadline, late_fraction):
1727def update_submission_credit(submission, deadline, late_fraction):
1728    """
1729    Helper for updating submission group credit based on timeliness
1730    given the specified `deadline`. Sets the "on_time" and
1731    "group_credit" slots of the submission. "on_time" is based on the
1732    "submitted_at" slot and the specified `deadline`; "group_credit" is
1733    based on the original "group_credit" value (or "credit" value if
1734    there is no "group_credit" value) and is multiplied by the
1735    `late_fraction` if the submission is not on-time.
1736
1737    Modifies the given submission dictionary; does not return anything.
1738    """
1739    # Get base credit value
1740    submission["group_credit"] = (
1741        submission.get(
1742            "group_credit",
1743            submission.get("credit", 0)
1744        )
1745    ) or 0  # this ensures None becomes 0 if there's an explicit None
1746
1747    # Figure out if it was on time or late
1748    if submission["submitted_at"] == "on_time":
1749        on_time = True
1750    elif submission["submitted_at"] == "late":
1751        on_time = False
1752    else:
1753        when = potluck.time_utils.time_from_timestring(
1754            submission["submitted_at"]
1755        )
1756        on_time = when <= deadline
1757
1758    # Update submission & possibly credit
1759    submission["on_time"] = on_time
1760    if not on_time:
1761        submission["group_credit"] *= late_fraction

Helper for updating submission group credit based on timeliness given the specified deadline. Sets the "on_time" and "group_credit" slots of the submission. "on_time" is based on the "submitted_at" slot and the specified deadline; "group_credit" is based on the original "group_credit" value (or "credit" value if there is no "group_credit" value) and is multiplied by the late_fraction if the submission is not on-time.

Modifies the given submission dictionary; does not return anything.

def fetch_best_outcomes(course, semester, username, exercise, deadline, late_fraction):
1764def fetch_best_outcomes(
1765    course,
1766    semester,
1767    username,
1768    exercise,
1769    deadline,
1770    late_fraction
1771):
1772    """
1773    Fetches the best outcome information for a particular
1774    course/semester/user/exercise. To do that, it needs to know what
1775    that user's current deadline is, and what credit multiplier to apply
1776    to late submissions. The deadline should be given as a
1777    `datetime.datetime` object. If the user has any manual overrides, the
1778    most recent of those will be returned.
1779
1780    The result will be a dictionary with the following keys:
1781
1782    - "submitted_at" - A time string (see
1783        `potluck.time_utils.timestring`) indicating when the list of
1784        outcomes was submitted. For overrides, might be the string
1785        "on_time" or the string "late" to indicate a specific lateness
1786        value regardless of deadline.
1787    - "on_time" - A boolean indicating whether the submission came in on
1788        time or not, based on the deadline given and the submission
1789        time.
1790    - "authors" - A list of usernames for participating authors, OR a
1791        list containing just the person entering the override for an
1792        override.
1793    - "outcomes" - A list of 3-tuple outcomes, which contain a boolean
1794        for success/failure followed by tag and message strings (see
1795        `optimism.listOutcomesInSuite`). For overrides, this is instead
1796        a Markdown string explaining things.
1797    - "code" - A list of filename/code-string pairs indicating any code
1798        blocks attached to the submission. For overrides, this is the
1799        special string "__override__" to indicate that they're overrides.
1800    - "status" - A status string describing the exercise status based on
1801        the outcomes which passed/failed.
1802    - "credit" - A number indicating how much credit this exercise is
1803        worth (higher = better) or possibly `None` if there is some issue
1804        with the submission (like wrong # of outcomes). This does not take
1805        timeliness into account.
1806    - "group_credit" - A credit number that accounts for timeliness, and
1807        which will always be a number (it's 0 when `credit` would be `None`).
1808
1809    Returns None if an error is encountered, or if the user has no
1810    submissions for that exercise.
1811    """
1812    best = None
1813    overrides = fetch_outcomes(
1814        course,
1815        semester,
1816        username,
1817        exercise,
1818        'override'
1819    )
1820    # Short-circuit and return most-recent override regardless of credit
1821    # if there is at least one.
1822    if overrides is None:
1823        overrides = []
1824    if len(overrides) > 0:
1825        update_submission_credit(overrides[-1], deadline, late_fraction)
1826        return overrides[-1]
1827
1828    fulls = fetch_outcomes(course, semester, username, exercise, 'full')
1829    if fulls is None:
1830        fulls = []
1831    for submission in reversed(fulls):  # iterate backwards chronologically
1832        update_submission_credit(submission, deadline, late_fraction)
1833
1834        if best is None or submission["group_credit"] >= best["group_credit"]:
1835            best = submission
1836            # It this one gets full credit; no need to look for better
1837            if submission["group_credit"] == 1:
1838                break
1839
1840    # Only look at partials if we didn't find a full-credit best
1841    # submission
1842    if best is None or best["group_credit"] < 1:
1843        partials = fetch_outcomes(
1844            course,
1845            semester,
1846            username,
1847            exercise,
1848            'partial'
1849        )
1850        if partials is None:
1851            partials = []
1852        for submission in partials:
1853            update_submission_credit(submission, deadline, late_fraction)
1854
1855            if (
1856                best is None
1857             or submission["group_credit"] >= best["group_credit"]
1858            ):
1859                best = submission
1860
1861    # Only look at no-credit submissions if we have no full or
1862    # partial-credit submissions at all.
1863    if best is None:
1864        nones = fetch_outcomes(
1865            course,
1866            semester,
1867            username,
1868            exercise,
1869            'none'
1870        )
1871        if nones is not None:
1872            # Always take chronologically last one; none of these are worth
1873            # any credit anyways.
1874            best = nones[-1]
1875            update_submission_credit(best, deadline, late_fraction)
1876
1877    # Try legacy info
1878    if best is None or best["group_credit"] < 1:
1879        legacy = fetch_old_outcomes(
1880            course,
1881            semester,
1882            username,
1883            exercise
1884        )
1885        if legacy is None:
1886            legacy = []
1887        for submission in legacy:
1888            old_on_time = submission.get("on_time", True)
1889            # figure out unpenalized credit if it had been marked late
1890            if old_on_time is False:
1891                submission["group_credit"] /= late_fraction
1892
1893            update_submission_credit(submission, deadline, late_fraction)
1894
1895            if (
1896                best is None
1897             or submission["group_credit"] >= best["group_credit"]
1898            ):
1899                best = submission
1900
1901    return best  # might still be None

Fetches the best outcome information for a particular course/semester/user/exercise. To do that, it needs to know what that user's current deadline is, and what credit multiplier to apply to late submissions. The deadline should be given as a datetime.datetime object. If the user has any manual overrides, the most recent of those will be returned.

The result will be a dictionary with the following keys:

  • "submitted_at" - A time string (see potluck.time_utils.timestring) indicating when the list of outcomes was submitted. For overrides, might be the string "on_time" or the string "late" to indicate a specific lateness value regardless of deadline.
  • "on_time" - A boolean indicating whether the submission came in on time or not, based on the deadline given and the submission time.
  • "authors" - A list of usernames for participating authors, OR a list containing just the person entering the override for an override.
  • "outcomes" - A list of 3-tuple outcomes, which contain a boolean for success/failure followed by tag and message strings (see optimism.listOutcomesInSuite). For overrides, this is instead a Markdown string explaining things.
  • "code" - A list of filename/code-string pairs indicating any code blocks attached to the submission. For overrides, this is the special string "__override__" to indicate that they're overrides.
  • "status" - A status string describing the exercise status based on the outcomes which passed/failed.
  • "credit" - A number indicating how much credit this exercise is worth (higher = better) or possibly None if there is some issue with the submission (like wrong # of outcomes). This does not take timeliness into account.
  • "group_credit" - A credit number that accounts for timeliness, and which will always be a number (it's 0 when credit would be None).

Returns None if an error is encountered, or if the user has no submissions for that exercise.

def save_outcomes( course, semester, username, exercise, authors, outcomes, codeBlocks, status, credit, group_credit):
1904def save_outcomes(
1905    course,
1906    semester,
1907    username,
1908    exercise,
1909    authors,
1910    outcomes,
1911    codeBlocks,
1912    status,
1913    credit,
1914    group_credit
1915):
1916    """
1917    Saves a list of outcomes for a specific exercise submitted by a
1918    particular user who is taking a course in a certain semester. The
1919    outcomes list should be a list of 3-tuples each consisting of a
1920    boolean, a tag string, and a message string (e.g., the return value
1921    from `optimism.listOutcomesInSuite`). The authors value should be a
1922    list of username strings listing all authors who contributed. The
1923    `codeBlocks` value should be a list of pairs, each of which has a
1924    filename string and a code string (the filename could also elsehow
1925    identify the source code was derived from). The `status` value should
1926    be a string describing the status of the submission, while the credit
1927    value should be a number between 0 and 1 (inclusive) where a higher
1928    number indicates a better submission.
1929
1930    The list of outcomes and associated code blocks is added to the
1931    record of all such lists submitted for that exercise by that user,
1932    categorized as 'none', 'partial', or 'full' depending on whether the
1933    credit value is 0, between 0 and 1, or 1. It will be stored as a
1934    dictionary with the following slots:
1935
1936    - "submitted_at": the current time, as a string (see
1937        `potluck.time_utils.timestring`).
1938    - "authors": The list of authors.
1939    - "outcomes": The list of outcomes.
1940    - "code": The list of filename/code-string pairs.
1941    - "status": The status string.
1942    - "credit": The credit number.
1943    - "group_credit": The credit number for counting group credit.
1944
1945    Returns True if it succeeds or False if it encounters some sort of
1946    error.
1947    """
1948    category = 'none'
1949    if group_credit > 1:
1950        raise ValueError(
1951            "Invalid group_credit value '{}' (must be <= 1).".format(
1952                group_credit
1953            )
1954        )
1955    elif group_credit == 1:
1956        category = 'full'
1957    elif group_credit > 0:
1958        category = 'partial'
1959
1960    # Redis key to use
1961    key = exercise_key(course, semester, username, exercise, category)
1962
1963    # Generate a timestamp for the info
1964    timestring = potluck.time_utils.timestring()
1965
1966    # Get old outcomes so we can add to them
1967    recorded_outcomes = fetch_outcomes(
1968        course,
1969        semester,
1970        username,
1971        exercise,
1972        category
1973    )
1974    if recorded_outcomes is None:
1975        recorded_outcomes = []
1976
1977    # Here's the info we store
1978    info = {
1979        "submitted_at": timestring,
1980        "authors": authors,
1981        "outcomes": outcomes,
1982        "code": codeBlocks,
1983        "status": status,
1984        "credit": credit,
1985        "group_credit": group_credit
1986    }
1987
1988    recorded_outcomes.append(info)
1989
1990    new_encoded = json.dumps(recorded_outcomes)
1991
1992    try:
1993        success = _REDIS.set(key, new_encoded)
1994        if success is not True:
1995            raise ValueError("Redis result indicated failure.")
1996    except Exception:
1997        flask.flash("Failed to write outcomes info!")
1998        tb = potluck.html_tools.string_traceback()
1999        print(
2000            "Failed to write outcomes info at '{}':\n{}".format(
2001                key,
2002                tb
2003            ),
2004            file=sys.stderr
2005        )
2006        return False
2007
2008    return True

Saves a list of outcomes for a specific exercise submitted by a particular user who is taking a course in a certain semester. The outcomes list should be a list of 3-tuples each consisting of a boolean, a tag string, and a message string (e.g., the return value from optimism.listOutcomesInSuite). The authors value should be a list of username strings listing all authors who contributed. The codeBlocks value should be a list of pairs, each of which has a filename string and a code string (the filename could also elsehow identify the source code was derived from). The status value should be a string describing the status of the submission, while the credit value should be a number between 0 and 1 (inclusive) where a higher number indicates a better submission.

The list of outcomes and associated code blocks is added to the record of all such lists submitted for that exercise by that user, categorized as 'none', 'partial', or 'full' depending on whether the credit value is 0, between 0 and 1, or 1. It will be stored as a dictionary with the following slots:

  • "submitted_at": the current time, as a string (see potluck.time_utils.timestring).
  • "authors": The list of authors.
  • "outcomes": The list of outcomes.
  • "code": The list of filename/code-string pairs.
  • "status": The status string.
  • "credit": The credit number.
  • "group_credit": The credit number for counting group credit.

Returns True if it succeeds or False if it encounters some sort of error.

def save_outcomes_override( course, semester, username, exercise, overrider, note, status, credit, time_override=None):
2011def save_outcomes_override(
2012    course,
2013    semester,
2014    username,
2015    exercise,
2016    overrider,
2017    note,
2018    status,
2019    credit,
2020    time_override=None
2021):
2022    """
2023    Saves an outcome override for a specific exercise submitted by a
2024    particular user who is taking a course in a certain semester.
2025
2026    The `overrider` should be the username of the person entering the
2027    override. The `note` must be a string, and will be rendered using
2028    Markdown to appear on the user's detailed view of the exercise in
2029    question.
2030
2031    The status and credit values are the same as for `save_outcomes`:
2032    `status` is a status string and `credit` is a floating-point number
2033    between 0 and 1 (inclusive).
2034
2035    If `time_override` is provided, it should be one of the strings
2036    "on_time" or "late" and the exercise will be marked as such
2037    regardless of the relationship between the deadline and the
2038    submission time. Note that the late penalty will be applied to the
2039    credit value for overrides which are marked as late.
2040
2041    TODO: Not that?
2042
2043    Only the most recent outcome override applies to a student's grade,
2044    but all outcome overrides will be visible to them.
2045    TODO: Allow for deleting/editing them!
2046
2047    The outcome override will be stored as a dictionary with the
2048    following slots:
2049
2050    - "submitted_at": the current time, as a string (see
2051        `potluck.time_utils.timestring`) or the provided `time_override`
2052        value.
2053    - "authors": A list containing just the `overrider`.
2054    - "outcomes": The `note` string.
2055    - "code": The special value `"__override__"` to mark this as an
2056        override.
2057    - "status": The provided status string.
2058    - "credit": The provided credit number.
2059    - "group_credit": A second copy of the credit number.
2060
2061    It is always stored in the "override" category outcomes storage.
2062
2063    Returns True if it succeeds or False if it encounters some sort of
2064    error.
2065    """
2066    # Redis key to use
2067    key = exercise_key(course, semester, username, exercise, 'override')
2068
2069    # Generate a timestamp for the info
2070    if time_override is None:
2071        timestring = potluck.time_utils.timestring()
2072    else:
2073        timestring = time_override
2074
2075    # Get old outcomes so we can add to them
2076    recorded_outcomes = fetch_outcomes(
2077        course,
2078        semester,
2079        username,
2080        exercise,
2081        'override'
2082    )
2083    if recorded_outcomes is None:
2084        recorded_outcomes = []
2085
2086    # Here's the info we store
2087    info = {
2088        "submitted_at": timestring,
2089        "authors": [overrider],
2090        "outcomes": note,
2091        "code": "__override__",
2092        "status": status,
2093        "credit": credit,
2094        "group_credit": credit
2095    }
2096
2097    recorded_outcomes.append(info)
2098
2099    new_encoded = json.dumps(recorded_outcomes)
2100
2101    try:
2102        success = _REDIS.set(key, new_encoded)
2103        if success is not True:
2104            raise ValueError("Redis result indicated failure.")
2105    except Exception:
2106        flask.flash("Failed to write exercise override info!")
2107        tb = potluck.html_tools.string_traceback()
2108        print(
2109            "Failed to write exercise override info at '{}':\n{}".format(
2110                key,
2111                tb
2112            ),
2113            file=sys.stderr
2114        )
2115        return False
2116
2117    return True

Saves an outcome override for a specific exercise submitted by a particular user who is taking a course in a certain semester.

The overrider should be the username of the person entering the override. The note must be a string, and will be rendered using Markdown to appear on the user's detailed view of the exercise in question.

The status and credit values are the same as for save_outcomes: status is a status string and credit is a floating-point number between 0 and 1 (inclusive).

If time_override is provided, it should be one of the strings "on_time" or "late" and the exercise will be marked as such regardless of the relationship between the deadline and the submission time. Note that the late penalty will be applied to the credit value for overrides which are marked as late.

TODO: Not that?

Only the most recent outcome override applies to a student's grade, but all outcome overrides will be visible to them. TODO: Allow for deleting/editing them!

The outcome override will be stored as a dictionary with the following slots:

  • "submitted_at": the current time, as a string (see potluck.time_utils.timestring) or the provided time_override value.
  • "authors": A list containing just the overrider.
  • "outcomes": The note string.
  • "code": The special value "__override__" to mark this as an override.
  • "status": The provided status string.
  • "credit": The provided credit number.
  • "group_credit": A second copy of the credit number.

It is always stored in the "override" category outcomes storage.

Returns True if it succeeds or False if it encounters some sort of error.

def default_feedback_summary():
2120def default_feedback_summary():
2121    """
2122    Returns a default summary object. The summary is a pared-down version
2123    of the full feedback .json file that stores the result of
2124    `potluck.render.render_report`, which in turn comes mostly from
2125    `potluck.rubrics.Rubric.evaluate`.
2126    """
2127    return {
2128        "submitted": False, # We didn't find any feedback file!
2129        "timestamp": "(not evaluated)",
2130        "partner_username": None,
2131        "evaluation": "not evaluated",
2132        "warnings": [ "We found no submission for this task." ],
2133        "is_default": True
2134    }

Returns a default summary object. The summary is a pared-down version of the full feedback .json file that stores the result of potluck.render.render_report, which in turn comes mostly from potluck.rubrics.Rubric.evaluate.

def get_feedback_summary(course, semester, task_info, username, phase, prid, taskid):
2137def get_feedback_summary(
2138    course,
2139    semester,
2140    task_info,
2141    username,
2142    phase,
2143    prid,
2144    taskid
2145):
2146    """
2147    This retrieves just the feedback summary information that appears on
2148    the dashboard for a given user/phase/project/task. That much info is
2149    light enough to cache, so we do cache it to prevent hitting the disk
2150    a lot for each dashboard view.
2151    """
2152    ts, log_file, report_file, status = get_inflight(
2153        course,
2154        semester,
2155        username,
2156        phase,
2157        prid,
2158        taskid
2159    )
2160    fallback = default_feedback_summary()
2161    if ts in (None, "error"):
2162        return fallback
2163    try:
2164        return load_or_get_cached(
2165            report_file,
2166            view=AsFeedbackSummary,
2167            missing=fallback,
2168            assume_fresh=_CONFIG.get("ASSUME_FRESH", 1)
2169        )
2170    except Exception:
2171        flask.flash("Failed to summarize feedback file.")
2172        return fallback

This retrieves just the feedback summary information that appears on the dashboard for a given user/phase/project/task. That much info is light enough to cache, so we do cache it to prevent hitting the disk a lot for each dashboard view.

def get_feedback(course, semester, task_info, username, phase, prid, taskid):
2175def get_feedback(
2176    course,
2177    semester,
2178    task_info,
2179    username,
2180    phase,
2181    prid,
2182    taskid
2183):
2184    """
2185    Gets feedback for the user's latest pre-deadline submission for the
2186    given phase/project/task. Instead of caching these values (which
2187    would be expensive memory-wise over time) we hit the disk every
2188    time.
2189
2190    Returns a dictionary with at least a 'status' entry. This will be
2191    'ok' if the report was read successfully, or 'missing' if the report
2192    file could not be read or did not exist. If the status is
2193    'missing', a 'log' entry will be present with the contents of the
2194    associated log file, or the string 'missing' if that log file could
2195    also not be read.
2196
2197    Weird stuff could happen if the file is being written as we make the
2198    request. Typically a second attempt should not re-encounter such an
2199    error.
2200    """
2201    result = { "status": "unknown" }
2202    ts, log_file, report_file, status = get_inflight(
2203        course,
2204        semester,
2205        username,
2206        phase,
2207        prid,
2208        taskid
2209    )
2210    if ts is None: # No submission
2211        result["status"] = "missing"
2212    elif ts == "error": # Failed to read inflight file
2213        flask.flash(
2214            "Failed to fetch in-flight info; please refresh the page."
2215        )
2216        result["status"] = "missing"
2217
2218    if result["status"] != "missing":
2219        try:
2220            if not os.path.exists(report_file):
2221                result["status"] = "missing"
2222            else:
2223                with open(report_file, 'r') as fin:
2224                    result = json.load(fin)
2225                    result["status"] = "ok"
2226        except Exception:
2227            flask.flash("Failed to read feedback file.")
2228            tb = potluck.html_tools.string_traceback()
2229            print(
2230                "Failed to read feedback file '{}':\n{}".format(
2231                    report_file,
2232                    tb
2233                ),
2234                file=sys.stderr
2235            )
2236            result["status"] = "missing"
2237
2238    if result["status"] == "ok":
2239        # Polish up warnings/evaluation a tiny bit
2240        warnings = result.get("warnings", [])
2241        evaluation = result.get("evaluation", "not evaluated")
2242        if evaluation == "incomplete" and len(warnings) == 0:
2243            warnings.append(
2244                "Your submission is incomplete"
2245              + " (it did not satisfy even half of the core goals)."
2246            )
2247        result["evaluation"] = evaluation
2248        result["warnings"] = warnings
2249        result["submitted"] = True
2250
2251    # Try to read log file if we couldn't get a report
2252    if result["status"] == "missing":
2253        if log_file is None:
2254            result["log"] = "no submission was made"
2255        else:
2256            try:
2257                if not os.path.exists(log_file):
2258                    result["log"] = "missing"
2259                else:
2260                    with open(log_file, 'r') as fin:
2261                        result["log"] = fin.read()
2262            except Exception:
2263                flask.flash("Error reading log file.")
2264                tb = potluck.html_tools.string_traceback()
2265                print(
2266                    "Failed to read log file '{}':\n{}".format(log_file, tb),
2267                    file=sys.stderr
2268                )
2269                result["log"] = "missing"
2270
2271    return result

Gets feedback for the user's latest pre-deadline submission for the given phase/project/task. Instead of caching these values (which would be expensive memory-wise over time) we hit the disk every time.

Returns a dictionary with at least a 'status' entry. This will be 'ok' if the report was read successfully, or 'missing' if the report file could not be read or did not exist. If the status is 'missing', a 'log' entry will be present with the contents of the associated log file, or the string 'missing' if that log file could also not be read.

Weird stuff could happen if the file is being written as we make the request. Typically a second attempt should not re-encounter such an error.

def get_feedback_html(course, semester, task_info, username, phase, prid, taskid):
2274def get_feedback_html(
2275    course,
2276    semester,
2277    task_info,
2278    username,
2279    phase,
2280    prid,
2281    taskid
2282):
2283    """
2284    Gets feedback for the user's latest pre-deadline submission for the
2285    given phase/project/task, as html instead of as json (see
2286    `get_feedback`). Instead of caching these values (which would be
2287    expensive memory-wise over time) we hit the disk every time.
2288
2289    Returns the string "missing" if the relevant feedback file does not
2290    exist, or if some kind of error occurs trying to access the file.
2291
2292    Might encounter an error if the file is being written as we try to
2293    read it.
2294    """
2295    result = None
2296    ts, log_file, report_file, status = get_inflight(
2297        course,
2298        semester,
2299        username,
2300        phase,
2301        prid,
2302        taskid
2303    )
2304    if ts is None: # No submission
2305        result = "missing"
2306    elif ts == "error": # Failed to read inflight file
2307        flask.flash(
2308            "Failed to read in-flight info; please refresh the page."
2309        )
2310        result = "missing"
2311
2312    if result != "missing":
2313        html_file = report_file[:-5] + ".html"
2314        try:
2315            if os.path.exists(html_file):
2316                # These include student code, instructions, etc., so it
2317                # would be expensive to cache them.
2318                with open(html_file, 'r') as fin:
2319                    result = fin.read()
2320                result = AsFeedbackHTML.decode(result)
2321            else:
2322                result = "missing"
2323        except Exception:
2324            flask.flash("Failed to read feedback report.")
2325            tb = potluck.html_tools.string_traceback()
2326            print(
2327                "Failed to read feedback report '{}':\n{}".format(
2328                    html_file,
2329                    tb
2330                ),
2331                file=sys.stderr
2332            )
2333            result = "missing"
2334
2335    return result

Gets feedback for the user's latest pre-deadline submission for the given phase/project/task, as html instead of as json (see get_feedback). Instead of caching these values (which would be expensive memory-wise over time) we hit the disk every time.

Returns the string "missing" if the relevant feedback file does not exist, or if some kind of error occurs trying to access the file.

Might encounter an error if the file is being written as we try to read it.

class View:
2342class View:
2343    """
2344    Abstract View class to organize decoding/encoding of views. Each View
2345    must define encode and decode class methods which are each others'
2346    inverse. The class name is used as part of the cache key. For
2347    read-only views, a exception (e.g., NotImplementedError) should be
2348    raised in the encode method.
2349
2350    Note that the decode method may be given None as a parameter in
2351    situations where a file doesn't exist, and in most cases should
2352    simply pass that value through.
2353    """
2354    @staticmethod
2355    def encode(obj):
2356        """
2357        The encode function of a View must return a string (to be written
2358        to a file).
2359        """
2360        raise NotImplementedError("Don't use the base View class.")
2361
2362    @staticmethod
2363    def decode(string):
2364        """
2365        The encode function of a View must accept a string, and if given
2366        a string produced by encode, should return an equivalent object.
2367        """
2368        raise NotImplementedError("Don't use the base View class.")

Abstract View class to organize decoding/encoding of views. Each View must define encode and decode class methods which are each others' inverse. The class name is used as part of the cache key. For read-only views, a exception (e.g., NotImplementedError) should be raised in the encode method.

Note that the decode method may be given None as a parameter in situations where a file doesn't exist, and in most cases should simply pass that value through.

View()
@staticmethod
def encode(obj):
2354    @staticmethod
2355    def encode(obj):
2356        """
2357        The encode function of a View must return a string (to be written
2358        to a file).
2359        """
2360        raise NotImplementedError("Don't use the base View class.")

The encode function of a View must return a string (to be written to a file).

@staticmethod
def decode(string):
2362    @staticmethod
2363    def decode(string):
2364        """
2365        The encode function of a View must accept a string, and if given
2366        a string produced by encode, should return an equivalent object.
2367        """
2368        raise NotImplementedError("Don't use the base View class.")

The encode function of a View must accept a string, and if given a string produced by encode, should return an equivalent object.

class AsIs(View):
2371class AsIs(View):
2372    """
2373    A pass-through view that returns strings unaltered.
2374    """
2375    @staticmethod
2376    def encode(obj):
2377        """Returns the object it is given unaltered."""
2378        return obj
2379
2380    @staticmethod
2381    def decode(string):
2382        """Returns the string it is given unaltered."""
2383        return string

A pass-through view that returns strings unaltered.

AsIs()
@staticmethod
def encode(obj):
2375    @staticmethod
2376    def encode(obj):
2377        """Returns the object it is given unaltered."""
2378        return obj

Returns the object it is given unaltered.

@staticmethod
def decode(string):
2380    @staticmethod
2381    def decode(string):
2382        """Returns the string it is given unaltered."""
2383        return string

Returns the string it is given unaltered.

class AsJSON(View):
2386class AsJSON(View):
2387    """
2388    A view that converts objects to JSON for file storage and back on
2389    access. It passes through None.
2390    """
2391    @staticmethod
2392    def encode(obj):
2393        """Returns the JSON encoding of the object."""
2394        return json.dumps(obj)
2395
2396    @staticmethod
2397    def decode(string):
2398        """
2399        Returns a JSON object parsed from the string.
2400        Returns None if it gets None.
2401        """
2402        if string is None:
2403            return None
2404        return json.loads(string)

A view that converts objects to JSON for file storage and back on access. It passes through None.

AsJSON()
@staticmethod
def encode(obj):
2391    @staticmethod
2392    def encode(obj):
2393        """Returns the JSON encoding of the object."""
2394        return json.dumps(obj)

Returns the JSON encoding of the object.

@staticmethod
def decode(string):
2396    @staticmethod
2397    def decode(string):
2398        """
2399        Returns a JSON object parsed from the string.
2400        Returns None if it gets None.
2401        """
2402        if string is None:
2403            return None
2404        return json.loads(string)

Returns a JSON object parsed from the string. Returns None if it gets None.

def build_view(name, encoder, decoder, pass_none=True):
2407def build_view(name, encoder, decoder, pass_none=True):
2408    """
2409    Function for building a view given a name, an encoding function, and
2410    a decoding function. Unless pass_none is given as False, the decoder
2411    will be skipped if the decode argument is None and the None will pass
2412    through, in which case the decoder will *always* get a string as an
2413    argument.
2414    """
2415    class SyntheticView(View):
2416        """
2417        View class created using build_view.
2418        """
2419        @staticmethod
2420        def encode(obj):
2421            return encoder(obj)
2422
2423        @staticmethod
2424        def decode(string):
2425            if pass_none and string is None:
2426                return None
2427            return decoder(string)
2428
2429    SyntheticView.__name__ = name
2430    SyntheticView.__doc__ = (
2431        "View that uses '{}' for encoding and '{}' for decoding."
2432    ).format(encoder.__name__, decoder.__name__)
2433    SyntheticView.encode.__doc__ = encoder.__doc__
2434    SyntheticView.decode.__doc__ = decoder.__doc__
2435
2436    return SyntheticView

Function for building a view given a name, an encoding function, and a decoding function. Unless pass_none is given as False, the decoder will be skipped if the decode argument is None and the None will pass through, in which case the decoder will always get a string as an argument.

class AsStudentInfo(View):
2439class AsStudentInfo(View):
2440    """
2441    Encoding and decoding for TSV student info files, which are cached.
2442    The student info structure is a dictionary mapping usernames to
2443    additional student info.
2444    """
2445    @staticmethod
2446    def encode(obj):
2447        """
2448        Student info *cannot* be encoded, because we are not interested
2449        in writing it to a file.
2450        TODO: Student info editing in-app?
2451        """
2452        raise NotImplementedError(
2453            "Cannot encode student info: student info is read-only."
2454        )
2455
2456    @staticmethod
2457    def decode(string):
2458        """
2459        Extra student info is read from a student info file by extracting
2460        the text, loading it as Excel-TSV data, and turning it into a
2461        dictionary where each student ID maps to a dictionary containing
2462        the columns as keys with values from that column as values.
2463        """
2464        reader = csv.DictReader(
2465            (line for line in string.strip().split('\n')),
2466            dialect="excel-tab"
2467        )
2468        result = {}
2469        for row in reader:
2470            entry = {}
2471            # TODO: Get this remap on a per-course basis!!!
2472            for key in _CONFIG["REMAP_STUDENT_INFO"]:
2473                entry[_CONFIG["REMAP_STUDENT_INFO"][key]] = row.get(key)
2474            entry["username"] = entry["email"].split('@')[0]
2475            result[entry['username']] = entry
2476        return result

Encoding and decoding for TSV student info files, which are cached. The student info structure is a dictionary mapping usernames to additional student info.

AsStudentInfo()
@staticmethod
def encode(obj):
2445    @staticmethod
2446    def encode(obj):
2447        """
2448        Student info *cannot* be encoded, because we are not interested
2449        in writing it to a file.
2450        TODO: Student info editing in-app?
2451        """
2452        raise NotImplementedError(
2453            "Cannot encode student info: student info is read-only."
2454        )

Student info cannot be encoded, because we are not interested in writing it to a file. TODO: Student info editing in-app?

@staticmethod
def decode(string):
2456    @staticmethod
2457    def decode(string):
2458        """
2459        Extra student info is read from a student info file by extracting
2460        the text, loading it as Excel-TSV data, and turning it into a
2461        dictionary where each student ID maps to a dictionary containing
2462        the columns as keys with values from that column as values.
2463        """
2464        reader = csv.DictReader(
2465            (line for line in string.strip().split('\n')),
2466            dialect="excel-tab"
2467        )
2468        result = {}
2469        for row in reader:
2470            entry = {}
2471            # TODO: Get this remap on a per-course basis!!!
2472            for key in _CONFIG["REMAP_STUDENT_INFO"]:
2473                entry[_CONFIG["REMAP_STUDENT_INFO"][key]] = row.get(key)
2474            entry["username"] = entry["email"].split('@')[0]
2475            result[entry['username']] = entry
2476        return result

Extra student info is read from a student info file by extracting the text, loading it as Excel-TSV data, and turning it into a dictionary where each student ID maps to a dictionary containing the columns as keys with values from that column as values.

class AsRoster(View):
2479class AsRoster(View):
2480    """
2481    Encoding and decoding for CSV rosters, which are cached. The roster
2482    structure is a dictionary mapping usernames to student info (see
2483    `load_roster_from_stream`).
2484    """
2485    @staticmethod
2486    def encode(obj):
2487        """
2488        A roster *cannot* be encoded, because we are not interested in
2489        writing it to a file.
2490        TODO: Roster editing in-app?
2491        """
2492        raise NotImplementedError(
2493            "Cannot encode a roster: rosters are read-only."
2494        )
2495
2496    @staticmethod
2497    def decode(string):
2498        """
2499        A roster is read from a roaster file by extracting the text and
2500        running it through `load_roster_from_stream`.
2501        """
2502        lines = string.strip().split('\n')
2503        return load_roster_from_stream(lines)

Encoding and decoding for CSV rosters, which are cached. The roster structure is a dictionary mapping usernames to student info (see load_roster_from_stream).

AsRoster()
@staticmethod
def encode(obj):
2485    @staticmethod
2486    def encode(obj):
2487        """
2488        A roster *cannot* be encoded, because we are not interested in
2489        writing it to a file.
2490        TODO: Roster editing in-app?
2491        """
2492        raise NotImplementedError(
2493            "Cannot encode a roster: rosters are read-only."
2494        )

A roster cannot be encoded, because we are not interested in writing it to a file. TODO: Roster editing in-app?

@staticmethod
def decode(string):
2496    @staticmethod
2497    def decode(string):
2498        """
2499        A roster is read from a roaster file by extracting the text and
2500        running it through `load_roster_from_stream`.
2501        """
2502        lines = string.strip().split('\n')
2503        return load_roster_from_stream(lines)

A roster is read from a roaster file by extracting the text and running it through load_roster_from_stream.

class AsFeedbackHTML(View):
2506class AsFeedbackHTML(View):
2507    """
2508    Encoding and decoding for feedback HTML files (we extract the body
2509    contents).
2510    """
2511    @staticmethod
2512    def encode(obj):
2513        """
2514        Feedback HTML *cannot* be encoded, because we want it to be
2515        read-only: it's produced by running potluck_eval, and the server
2516        won't edit it.
2517        """
2518        raise NotImplementedError(
2519            "Cannot encode feedback HTML: feedback is read-only."
2520        )
2521
2522    @staticmethod
2523    def decode(string):
2524        """
2525        Feedback HTML is read from the raw HTML file by extracting the
2526        innerHTML of the body tag using Beautiful Soup. Returns a default
2527        string if the file wasn't found.
2528        """
2529        if string is None: # happens when the target file doesn't exist
2530            return "no feedback available"
2531        soup = bs4.BeautifulSoup(string, "html.parser")
2532        body = soup.find("body")
2533        return str(body)

Encoding and decoding for feedback HTML files (we extract the body contents).

AsFeedbackHTML()
@staticmethod
def encode(obj):
2511    @staticmethod
2512    def encode(obj):
2513        """
2514        Feedback HTML *cannot* be encoded, because we want it to be
2515        read-only: it's produced by running potluck_eval, and the server
2516        won't edit it.
2517        """
2518        raise NotImplementedError(
2519            "Cannot encode feedback HTML: feedback is read-only."
2520        )

Feedback HTML cannot be encoded, because we want it to be read-only: it's produced by running potluck_eval, and the server won't edit it.

@staticmethod
def decode(string):
2522    @staticmethod
2523    def decode(string):
2524        """
2525        Feedback HTML is read from the raw HTML file by extracting the
2526        innerHTML of the body tag using Beautiful Soup. Returns a default
2527        string if the file wasn't found.
2528        """
2529        if string is None: # happens when the target file doesn't exist
2530            return "no feedback available"
2531        soup = bs4.BeautifulSoup(string, "html.parser")
2532        body = soup.find("body")
2533        return str(body)

Feedback HTML is read from the raw HTML file by extracting the innerHTML of the body tag using Beautiful Soup. Returns a default string if the file wasn't found.

class AsFeedbackSummary(View):
2536class AsFeedbackSummary(View):
2537    """
2538    Encoding and decoding for feedback summaries, which are cached.
2539    """
2540    @staticmethod
2541    def encode(obj):
2542        """
2543        A feedback summary *cannot* be encoded, because it cannot be
2544        written to a file. Feedback summaries are only read from full
2545        feedback files, never written.
2546        """
2547        raise NotImplementedError(
2548            "Cannot encode a feedback summary: summaries are read-only."
2549        )
2550
2551    @staticmethod
2552    def decode(string):
2553        """
2554        A feedback summary is read from a feedback file by extracting the
2555        full JSON feedback and then paring it down to just the essential
2556        information for the dashboard view.
2557        """
2558        if string is None: # happens when the target file doesn't exist
2559            return default_feedback_summary()
2560        # Note taskid is nonlocal here
2561        raw_report = json.loads(string)
2562        warnings = raw_report.get("warnings", [])
2563        evaluation = raw_report.get("evaluation", "not evaluated")
2564        if evaluation == "incomplete" and len(warnings) == 0:
2565            warnings.append(
2566                "Your submission is incomplete"
2567              + " (it did not satisfy even half of the core goals)."
2568            )
2569        return {
2570            "submitted": True,
2571            "partner_username": raw_report.get("partner_username"),
2572            "timestamp": raw_report.get("timestamp"),
2573            "evaluation": evaluation,
2574            "warnings": warnings,
2575            "is_default": False
2576            # report summary, files, table, and contexts omitted
2577        }

Encoding and decoding for feedback summaries, which are cached.

AsFeedbackSummary()
@staticmethod
def encode(obj):
2540    @staticmethod
2541    def encode(obj):
2542        """
2543        A feedback summary *cannot* be encoded, because it cannot be
2544        written to a file. Feedback summaries are only read from full
2545        feedback files, never written.
2546        """
2547        raise NotImplementedError(
2548            "Cannot encode a feedback summary: summaries are read-only."
2549        )

A feedback summary cannot be encoded, because it cannot be written to a file. Feedback summaries are only read from full feedback files, never written.

@staticmethod
def decode(string):
2551    @staticmethod
2552    def decode(string):
2553        """
2554        A feedback summary is read from a feedback file by extracting the
2555        full JSON feedback and then paring it down to just the essential
2556        information for the dashboard view.
2557        """
2558        if string is None: # happens when the target file doesn't exist
2559            return default_feedback_summary()
2560        # Note taskid is nonlocal here
2561        raw_report = json.loads(string)
2562        warnings = raw_report.get("warnings", [])
2563        evaluation = raw_report.get("evaluation", "not evaluated")
2564        if evaluation == "incomplete" and len(warnings) == 0:
2565            warnings.append(
2566                "Your submission is incomplete"
2567              + " (it did not satisfy even half of the core goals)."
2568            )
2569        return {
2570            "submitted": True,
2571            "partner_username": raw_report.get("partner_username"),
2572            "timestamp": raw_report.get("timestamp"),
2573            "evaluation": evaluation,
2574            "warnings": warnings,
2575            "is_default": False
2576            # report summary, files, table, and contexts omitted
2577        }

A feedback summary is read from a feedback file by extracting the full JSON feedback and then paring it down to just the essential information for the dashboard view.

def build_file_freshness_checker(missing=<class 'Exception'>, assume_fresh=0, cache={}):
2615def build_file_freshness_checker(
2616    missing=Exception,
2617    assume_fresh=0,
2618    cache={}
2619):
2620    """
2621    Builds a freshness checker that checks the mtime of a filename, but
2622    if that file doesn't exist, it returns AbortGeneration with the given
2623    missing value (unless missing is left as the default of Exception, in
2624    which case it lets the exception bubble out).
2625
2626    If assume_fresh is set to a positive number, and less than that many
2627    seconds have elapsed since the most recent mtime check, the mtime
2628    check is skipped and the file is assumed to be fresh.
2629    """
2630    ck = (id(missing), assume_fresh)
2631    if ck in cache:
2632        return cache[ck]
2633
2634    def check_file_is_changed(cache_key, ts):
2635        """
2636        Checks whether a file has been modified more recently than the given
2637        timestamp.
2638        """
2639        global _FRESH_AT
2640        now = time.time()
2641        cached_fresh = _FRESH_AT.get(cache_key)
2642        if cached_fresh is not None:
2643            cached_start, cached_end = cached_fresh
2644            if (
2645                ts is not None
2646            and ts >= cached_start
2647                # Note we don't check ts <= cached_end here
2648            and (now - cached_end) < _CONFIG.get("ASSUME_FRESH", 1)
2649            ):
2650                return False  # we assume it has NOT changed
2651
2652        filename = cache_key_filename(cache_key)
2653        try:
2654            mtime = os.path.getmtime(filename)
2655        except OSError_or_FileNotFoundError:
2656            if missing == Exception:
2657                raise
2658            else:
2659                return AbortGeneration(missing)
2660
2661        # File is changed if the mtime is after the given cache
2662        # timestamp, or if the timestamp is None
2663        result = ts is None or mtime >= ts
2664        if result:
2665            # If we find that a specific time was checked, and that time
2666            # was after the beginning of the current cached fresh period,
2667            # we erase the cached fresh period, since result being true
2668            # means the file HAS changed.
2669            if (
2670                ts is not None
2671            and (
2672                    cached_fresh is not None
2673                and ts > cached_start
2674                )
2675            ):
2676                _FRESH_AT[cache_key] = None
2677        else:
2678            # If the file WASN'T changed, we extend the cache freshness
2679            # (In this branch ts is NOT None and it's strictly greater
2680            # than mtime)
2681            if cached_fresh is not None:
2682                # If an earlier-time-point was checked and the check
2683                # succeeded, we can extend the fresh-time-span backwards
2684                if ts < cached_start:
2685                    cached_start = ts
2686
2687                # Likewise, we might be able to extend it forwards
2688                if ts > cached_end:
2689                    cached_end = ts
2690
2691                # Update the time-span
2692                _FRESH_AT[cache_key] = (cached_start, cached_end)
2693            else:
2694                # If we didn't have cached freshness, initialize it
2695                _FRESH_AT[cache_key] = (ts, ts)
2696
2697        return result
2698
2699    cache[ck] = check_file_is_changed
2700    return check_file_is_changed

Builds a freshness checker that checks the mtime of a filename, but if that file doesn't exist, it returns AbortGeneration with the given missing value (unless missing is left as the default of Exception, in which case it lets the exception bubble out).

If assume_fresh is set to a positive number, and less than that many seconds have elapsed since the most recent mtime check, the mtime check is skipped and the file is assumed to be fresh.

def build_file_reader(view=<class 'potluck_server.storage.AsJSON'>):
2703def build_file_reader(view=AsJSON):
2704    """
2705    Builds a file reader function which returns the result of the given
2706    view on the file contents.
2707    """
2708    def read_file(cache_key):
2709        """
2710        Reads a file and returns the result of calling a view's decode
2711        function on the file contents. Returns None if there's an error,
2712        and prints the error unless it's a FileNotFoundError.
2713        """
2714        filename = cache_key_filename(cache_key)
2715        try:
2716            with open(filename, 'r') as fin:
2717                return view.decode(fin.read())
2718        except IOError_or_FileNotFoundError:
2719            return None
2720        except Exception as e:
2721            sys.stderr.write(
2722                "[sync module] Exception viewing file:\n" + str(e) + '\n'
2723            )
2724            return None
2725
2726    return read_file

Builds a file reader function which returns the result of the given view on the file contents.

def cache_key_for(target, view):
2733def cache_key_for(target, view):
2734    """
2735    Builds a hybrid cache key value with a certain target and view. The
2736    target filename must not include '::'.
2737    """
2738    if '::' in target:
2739        raise ValueError(
2740            "Cannot use a filename with a '::' in it as the target"
2741            " file."
2742        )
2743    return target + '::' + view.__name__

Builds a hybrid cache key value with a certain target and view. The target filename must not include '::'.

def cache_key_filename(cache_key):
2746def cache_key_filename(cache_key):
2747    """
2748    Returns just the filename given a cache key.
2749    """
2750    filename = None
2751    for i in range(len(cache_key) - 1):
2752        if cache_key[i:i + 2] == '::':
2753            filename = cache_key[:i]
2754            break
2755    if filename is None:
2756        raise ValueError("Value '{}' is not a cache key!".format(cache_key))
2757
2758    return filename

Returns just the filename given a cache key.

def load_or_get_cached( filename, view=<class 'potluck_server.storage.AsJSON'>, missing=<class 'Exception'>, assume_fresh=0):
2761def load_or_get_cached(
2762    filename,
2763    view=AsJSON,
2764    missing=Exception,
2765    assume_fresh=0
2766):
2767    """
2768    Reads the given file, returning its contents as a string. Doesn't
2769    actually do that most of the time. Instead, it will return a cached
2770    value. And instead of returning the contents of the file as a
2771    string, it returns the result of running the given view function on
2772    the file's contents (decoded as a string). And actually, it caches
2773    the view result, not the file contents, to save time reapplying the
2774    view. The default view is AsJSON, which loads the file contents as
2775    JSON and creates a Python object.
2776
2777    The __name__ of the view class will be used to compute a cache key
2778    for that view; avoid view name collisions.
2779
2780    If the file on disk is newer than the cache, re-reads and re-caches
2781    the file. If assume_fresh is set to a positive number, then the file
2782    time on disk isn't even checked if the most recent check was
2783    performed less than that many seconds ago.
2784
2785    If the file is missing, an exception would normally be raised, but
2786    if the `missing` value is provided as something other than
2787    `Exception`, a deep copy of that value will be returned instead.
2788
2789    Note: On a cache hit, a deep copy of the cached value is returned, so
2790    modifying that value should not affect what is stored in the cache.
2791    """
2792
2793    # Figure out our view object (& cache key):
2794    if view is None:
2795        view = AsIs
2796
2797    cache_key = cache_key_for(filename, view)
2798
2799    # Build functions for checking freshness and reading the file
2800    check_mtime = build_file_freshness_checker(missing, assume_fresh)
2801    read_file = build_file_reader(view)
2802
2803    return _gen_or_get_cached(
2804        _CACHE_LOCK,
2805        _CACHE,
2806        cache_key,
2807        check_mtime,
2808        read_file
2809    )

Reads the given file, returning its contents as a string. Doesn't actually do that most of the time. Instead, it will return a cached value. And instead of returning the contents of the file as a string, it returns the result of running the given view function on the file's contents (decoded as a string). And actually, it caches the view result, not the file contents, to save time reapplying the view. The default view is AsJSON, which loads the file contents as JSON and creates a Python object.

The __name__ of the view class will be used to compute a cache key for that view; avoid view name collisions.

If the file on disk is newer than the cache, re-reads and re-caches the file. If assume_fresh is set to a positive number, then the file time on disk isn't even checked if the most recent check was performed less than that many seconds ago.

If the file is missing, an exception would normally be raised, but if the missing value is provided as something other than Exception, a deep copy of that value will be returned instead.

Note: On a cache hit, a deep copy of the cached value is returned, so modifying that value should not affect what is stored in the cache.

class AbortGeneration:
2816class AbortGeneration:
2817    """
2818    Class to signal that generation of a cached item should not proceed.
2819    Holds a default value to return instead.
2820    """
2821    def __init__(self, replacement):
2822        self.replacement = replacement

Class to signal that generation of a cached item should not proceed. Holds a default value to return instead.

AbortGeneration(replacement)
2821    def __init__(self, replacement):
2822        self.replacement = replacement
class NotInCache:
2825class NotInCache:
2826    """
2827    Placeholder for recognizing that a value is not in the cache (when
2828    e.g., None might be a valid cache value).
2829    """
2830    pass

Placeholder for recognizing that a value is not in the cache (when e.g., None might be a valid cache value).

NotInCache()
def init(config, key=None):
2910def init(config, key=None):
2911    """
2912    `init` should be called once per process, ideally early in the life of
2913    the process, like right after importing the module. Calling
2914    some functions before `init` will fail. A file named 'redis-pw.conf'
2915    should exist unless a key is given (should be a byte-string). If
2916    'redis-pw.conf' doesn't exist, it will be created.
2917    """
2918    global _REDIS, _CONFIG, _EVAL_BASE
2919
2920    # Store config object
2921    _CONFIG = config
2922
2923    # Compute evaluation base directory based on init-time CWD
2924    _EVAL_BASE = os.path.join(os.getcwd(), _CONFIG["EVALUATION_BASE"])
2925
2926    # Grab port from config
2927    port = config.get("STORAGE_PORT", 51723)
2928
2929    # Redis configuration filenames
2930    rconf_file = "potluck-redis.conf"
2931    ruser_file = "potluck-redis-user.acl"
2932    rport_file = "potluck-redis-port.conf"
2933    rpid_file = "potluck-redis.pid"
2934    rlog_file = "potluck-redis.log"
2935
2936    # Check for redis config file
2937    if not os.path.exists(rconf_file):
2938        raise IOError_or_FileNotFoundError(
2939            "Unable to find Redis configuration file '{}'.".format(
2940                rconf_file
2941            )
2942        )
2943
2944    # Check that conf file contains required stuff
2945    # TODO: More flexibility about these things?
2946    with open(rconf_file, 'r') as fin:
2947        rconf = fin.read()
2948        adir = 'aclfile "{}"'.format(ruser_file)
2949        if adir not in rconf:
2950            raise ValueError(
2951                (
2952                    "Redis configuration file '{}' is missing an ACL"
2953                    " file directive for the ACL file. It needs to use"
2954                    " '{}'."
2955                ).format(rconf_file, adir)
2956            )
2957
2958        incl = "include {}".format(rport_file)
2959        if incl not in rconf:
2960            raise ValueError(
2961                (
2962                    "Redis configuration file '{}' is missing an include"
2963                    " for the port file. It needs to use '{}'."
2964                ).format(rconf_file, incl)
2965            )
2966
2967        pdecl = 'pidfile "{}"'.format(rpid_file)
2968        if pdecl not in rconf:
2969            raise ValueError(
2970                (
2971                    "Redis configuration file '{}' is missing the"
2972                    " correct PID file directive '{}'."
2973                ).format(rconf_file, pdecl)
2974            )
2975
2976        ldecl = 'logfile "{}"'.format(rlog_file)
2977        if ldecl not in rconf:
2978            raise ValueError(
2979                (
2980                    "Redis configuration file '{}' is missing the"
2981                    " correct log file directive '{}'."
2982                ).format(rconf_file, ldecl)
2983            )
2984
2985    # Get storage key:
2986    if key is None:
2987        try:
2988            if os.path.exists(ruser_file):
2989                with open(ruser_file, 'r') as fin:
2990                    key = fin.read().strip().split()[-1][1:]
2991            else:
2992                print(
2993                    "Creating new Redis user file '{}'.".format(
2994                        ruser_file
2995                    )
2996                )
2997                # b32encode here makes it more readable
2998                key = base64.b32encode(os.urandom(64)).decode("ascii")
2999                udecl = "user default on +@all ~* >{}".format(key)
3000                with open(ruser_file, 'w') as fout:
3001                    fout.write(udecl)
3002        except Exception:
3003            raise IOError_or_FileNotFoundError(
3004                "Unable to access user file '{}'.".format(ruser_file)
3005            )
3006
3007    # Double-check port, or write port conf file
3008    if os.path.exists(rport_file):
3009        with open(rport_file, 'r') as fin:
3010            portstr = fin.read().strip().split()[1]
3011        try:
3012            portconf = int(portstr)
3013        except Exception:
3014            portconf = portstr
3015
3016        if portconf != port:
3017            raise ValueError(
3018                (
3019                    "Port was specified as {}, but port conf file"
3020                    " already exists and says port should be {}."
3021                    " Delete the port conf file '{}' to re-write it."
3022                ).format(repr(port), repr(portconf), rport_file)
3023            )
3024    else:
3025        # We need to write the port into the config file
3026        with open(rport_file, 'w') as fout:
3027            fout.write("port " + str(port))
3028
3029    _REDIS = redis.Redis(
3030        'localhost',
3031        port,
3032        password=key,
3033        decode_responses=True
3034    )
3035    # Attempt to connect; if that fails, attempt to start a new Redis
3036    # server and attempt to connect again. Abort if we couldn't start
3037    # the server.
3038    print("Attempting to connect to Redis server...")
3039    try:
3040        _REDIS.exists('test') # We just want to not trigger an error
3041        print("...connected successfully.")
3042    except redis.exceptions.ConnectionError: # nobody to connect to
3043        _REDIS = None
3044    except redis.exceptions.ResponseError: # bad password
3045        raise ValueError(
3046            "Your authentication key is not correct. Make sure"
3047            " you're not sharing the port you chose with another"
3048            " process!"
3049        )
3050
3051    if _REDIS is None:
3052        print("...failed to connect...")
3053        if os.path.exists(rpid_file):
3054            print(
3055                (
3056                    "...a Redis PID file already exists at '{}', but we"
3057                    " can't connect. Please shut down the old Redis"
3058                    " server first, or clean up the PID file if it"
3059                    " crashed."
3060                ).format(rpid_file)
3061            )
3062            raise ValueError(
3063                "Aborting server startup due to existing PID file."
3064            )
3065
3066        # Try to start a new redis server...
3067        print("...starting Redis...")
3068        try:
3069            subprocess.Popen(["redis-server", rconf_file])
3070        except OSError:
3071            # If running through e.g. Apache and this command fails, you
3072            # can try to start it manually, so we point that out
3073            if len(shlex.split(rconf_file)) > 1:
3074                rconf_arg = "'" + rconf_file.replace("'", r"\'") + "'"
3075            else:
3076                rconf_arg = rconf_file
3077            sys.stdout.write(
3078                (
3079                    "Note: Failed to start redis-server with an"
3080                    " OSError.\nYou could try to manually launch the"
3081                    " server by running:\nredis-server {}"
3082                ).format(rconf_arg)
3083            )
3084            raise
3085        time.sleep(0.2) # try to connect pretty quickly
3086        print("...we hope Redis is up now...")
3087
3088        if not os.path.exists(rpid_file):
3089            print(
3090                (
3091                    "...looks like Redis failed to launch; check '{}'..."
3092                ).format(rlog_file)
3093            )
3094
3095        # We'll try a second time to connect
3096        _REDIS = redis.Redis(
3097            'localhost',
3098            port,
3099            password=key,
3100            decode_responses=True
3101        )
3102        print("Reattempting connection to Redis server...")
3103        try:
3104            _REDIS.exists('test') # We just want to not get an error
3105            print("...connected successfully.")
3106        except redis.exceptions.ConnectionError: # Not ready yet
3107            print("...not ready on first attempt...")
3108            _REDIS = None
3109        except redis.exceptions.ResponseError: # bad password
3110            raise ValueError(
3111                "Your authentication key is not correct. Make sure"
3112                " you're not sharing the port you chose with another"
3113                " process!"
3114            )
3115
3116        # We'll make one final attempt
3117        if _REDIS is None:
3118            time.sleep(2) # Give it plenty of time
3119
3120            # Check for PID file
3121            if not os.path.exists(rpid_file):
3122                print(
3123                    (
3124                        "...looks like Redis is still not running;"
3125                        " check '{}'..."
3126                    ).format(rlog_file)
3127                )
3128
3129            # Set up connection object
3130            _REDIS = redis.Redis(
3131                'localhost',
3132                port,
3133                password=key,
3134                decode_responses=True
3135            )
3136            # Attempt to connect
3137            print(
3138                "Reattempting connection to Redis server (last"
3139                " chance)..."
3140            )
3141            try:
3142                _REDIS.exists('test') # We just want to not get an error
3143                print("...connected successfully.")
3144            except redis.exceptions.ResponseError: # bad password
3145                raise ValueError(
3146                    "Your authentication key is not correct. Make sure"
3147                    " you're not sharing the port you chose with another"
3148                    " process!"
3149                )
3150            # This time, we'll let a connection error bubble out
3151
3152    # At this point, _REDIS is a working connection.

init should be called once per process, ideally early in the life of the process, like right after importing the module. Calling some functions before init will fail. A file named 'redis-pw.conf' should exist unless a key is given (should be a byte-string). If 'redis-pw.conf' doesn't exist, it will be created.