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.
The version for the schema used to organize information under keys in Redis. If this changes, all Redis keys will change.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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:SCHEMA_VERSION
).
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.
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.
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.
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.
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.
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
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.
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.
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.
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.
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.
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.
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.
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
).
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.
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.
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.
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.
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.
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.
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...).
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.
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.
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
.
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.
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.
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; seefetch_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.
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.
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 beNone
).
Returns None if an error is encountered, or if the user has no submissions for that exercise.
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.
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 providedtime_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.
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
.
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.
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.
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.
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.
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).
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.
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.
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.
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.
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.
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.
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.
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?
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.
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
).
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?
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
.
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).
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.
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.
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.
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.
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.
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.
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.
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 '::'.
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.
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.
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.
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).
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.