From 188cc233e5157690632994bf193a20d9b41a82b5 Mon Sep 17 00:00:00 2001 From: rahularya Date: Mon, 17 Feb 2020 03:29:58 -0800 Subject: [PATCH 01/34] provide sensible oauth defaults --- config.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/config.py b/config.py index 2e3cbbc..c8b405a 100644 --- a/config.py +++ b/config.py @@ -1,9 +1,9 @@ # Define the application directory import os -BASE_DIR = os.path.abspath(os.path.dirname(__file__)) +BASE_DIR = os.path.abspath(os.path.dirname(__file__)) -# Define the database -# We are working with SQLite for development, mysql for production +# Define the database +# We are working with SQLite for development, mysql for production # [development should be changed to mysql in the future] if os.getenv('FLASK_ENV') == 'development': SQLALCHEMY_DATABASE_URI = 'sqlite:///' + os.path.join(BASE_DIR, 'app.db') @@ -16,8 +16,8 @@ # Configure other environment variables -OK_CLIENT_ID = os.getenv('OK_CLIENT_ID') -OK_CLIENT_SECRET = os.getenv('OK_CLIENT_SECRET') +OK_CLIENT_ID = os.getenv('OK_CLIENT_ID', "local-dev-all") +OK_CLIENT_SECRET = os.getenv('OK_CLIENT_SECRET', "kmSPJYPzKJglOOOmr7q0irMfBVMRFXN") GOOGLE_OAUTH2_CLIENT_ID = os.getenv('GOOGLE_CLIENT_ID') GOOGLE_OAUTH2_CLIENT_SECRET = os.getenv('GOOGLE_CLIENT_SECRET') From b59f38ae89065e2518445d8eb6f5773d8fe04c70 Mon Sep 17 00:00:00 2001 From: rahularya Date: Mon, 17 Feb 2020 03:46:12 -0800 Subject: [PATCH 02/34] removed google API, switched to Auth --- config.py | 4 +- download_bcourses_photos.py | 402 ++++++++++++++++++++++++------------ server/auth.py | 13 +- server/models.py | 41 ++-- server/views.py | 102 +++++---- 5 files changed, 358 insertions(+), 204 deletions(-) diff --git a/config.py b/config.py index c8b405a..f28dfee 100644 --- a/config.py +++ b/config.py @@ -19,8 +19,8 @@ OK_CLIENT_ID = os.getenv('OK_CLIENT_ID', "local-dev-all") OK_CLIENT_SECRET = os.getenv('OK_CLIENT_SECRET', "kmSPJYPzKJglOOOmr7q0irMfBVMRFXN") -GOOGLE_OAUTH2_CLIENT_ID = os.getenv('GOOGLE_CLIENT_ID') -GOOGLE_OAUTH2_CLIENT_SECRET = os.getenv('GOOGLE_CLIENT_SECRET') +AUTH_KEY = os.getenv("AUTH_KEY", "seating-app") +AUTH_CLIENT_SECRET = os.getenv("AUTH_CLIENT_SECRET") # Email setup. Domain environment is for link in email. SENDGRID_API_KEY = os.getenv('SENDGRID_API_KEY') diff --git a/download_bcourses_photos.py b/download_bcourses_photos.py index 5036992..fdff9f1 100644 --- a/download_bcourses_photos.py +++ b/download_bcourses_photos.py @@ -11,77 +11,126 @@ import sys import threading -try: from queue import Queue # Python 3 -except ImportError: from Queue import Queue # Python 2 +try: + from queue import Queue # Python 3 +except ImportError: + from Queue import Queue # Python 2 + +try: + import urllib.request as urllib2 # Python 3 +except ImportError: + import urllib2 # Python 2 -try: import urllib.request as urllib2 # Python 3 -except ImportError: import urllib2 # Python 2 def urlopen(url, *args, **kwargs): - cookie = kwargs.pop('cookie', None) - opener = urllib2.build_opener() - if cookie is not None: opener.addheaders.append(('Cookie', cookie)) - return opener.open(url, *args, **kwargs) + cookie = kwargs.pop("cookie", None) + opener = urllib2.build_opener() + if cookie is not None: + opener.addheaders.append(("Cookie", cookie)) + return opener.open(url, *args, **kwargs) + def get_content_charset(headers): - try: get_param = headers.get_param # Python 3 - except AttributeError: get_param = headers.getparam # Python 2 - return get_param("charset") or 'utf-8' + try: + get_param = headers.get_param # Python 3 + except AttributeError: + get_param = headers.getparam # Python 2 + return get_param("charset") or "utf-8" + def read_http_response_as_json(response): - return json.load(response, encoding=get_content_charset(response.headers)) + return json.load(response, encoding=get_content_charset(response.headers)) + def fetcher(q): - mime_type_ranks = ['.jpg', '.png', '.tif', '.bmp'] - while 1: - task = q.get() - if task is None: break - (url, cookie, path) = task - response = None - try: response = urlopen(url, cookie=cookie) - except Exception as ex: - sys.stderr.write(repr(ex)); sys.stderr.flush() - if response and response.getcode() == 200: - data = response.read() - ext = "".join(sorted(mimetypes.guess_all_extensions(response.headers['Content-Type']), key=lambda ext: mime_type_ranks.index(ext.lower()) if ext.lower() in mime_type_ranks else len(mime_type_ranks))[:1]) - if len(data) > 0: - path_with_ext = path + ext - if not os.path.exists(path_with_ext): - path_parent = os.path.dirname(path_with_ext) - try: - os.makedirs(path_parent) - except OSError as ex: - if not (ex.errno == errno.EEXIST and os.path.isdir(path_parent)): - raise - with open(path_with_ext, 'w+b') as f: - f.write(data) - f.close() - sys.stdout.write("< " + path_with_ext + "\n"); sys.stdout.flush() - else: - sys.stderr.write("! " + path_with_ext + "\n"); sys.stderr.flush() - q.task_done() + mime_type_ranks = [".jpg", ".png", ".tif", ".bmp"] + while 1: + task = q.get() + if task is None: + break + (url, cookie, path) = task + response = None + try: + response = urlopen(url, cookie=cookie) + except Exception as ex: + sys.stderr.write(repr(ex)) + sys.stderr.flush() + if response and response.getcode() == 200: + data = response.read() + ext = "".join( + sorted( + mimetypes.guess_all_extensions(response.headers["Content-Type"]), + key=lambda ext: mime_type_ranks.index(ext.lower()) + if ext.lower() in mime_type_ranks + else len(mime_type_ranks), + )[:1] + ) + if len(data) > 0: + path_with_ext = path + ext + if not os.path.exists(path_with_ext): + path_parent = os.path.dirname(path_with_ext) + try: + os.makedirs(path_parent) + except OSError as ex: + if not ( + ex.errno == errno.EEXIST and os.path.isdir(path_parent) + ): + raise + with open(path_with_ext, "w+b") as f: + f.write(data) + f.close() + sys.stdout.write("< " + path_with_ext + "\n") + sys.stdout.flush() + else: + sys.stderr.write("! " + path_with_ext + "\n") + sys.stderr.flush() + q.task_done() + def find_cookies_in_cookies_txt(for_domain, content): - matches = [] - for match in re.finditer("^\\s*([^#\\t\\r\\n]+).*\\s+([^#\\t\\r\\n]*[\\S])\\s+([^#\\t\\r\\n]*[\\S])\\s*$", content, re.MULTILINE): - domain = match.group(1) - if for_domain == domain or ("." + for_domain).endswith("." + domain.lstrip(".")): - matches.append(match.group(2) + "=" + match.group(3)) - return matches + matches = [] + for match in re.finditer( + "^\\s*([^#\\t\\r\\n]+).*\\s+([^#\\t\\r\\n]*[\\S])\\s+([^#\\t\\r\\n]*[\\S])\\s*$", + content, + re.MULTILINE, + ): + domain = match.group(1) + if for_domain == domain or ("." + for_domain).endswith( + "." + domain.lstrip(".") + ): + matches.append(match.group(2) + "=" + match.group(3)) + return matches + + +ROSTER_FIELDS = [ + "id", + "first_name", + "last_name", + "student_id", + "email", + "login_id", + "photo", + "section_ccns", + "enroll_status", + "grade_option", + "units", + "sections", +] # Not set in stone: the server may add/remove fields as it wishes... -ROSTER_FIELDS = ['id', 'first_name', 'last_name', 'student_id', 'email', 'login_id', 'photo', 'section_ccns', 'enroll_status', 'grade_option', 'units', 'sections'] # Not set in stone: the server may add/remove fields as it wishes... def main(program, *args): - program_name = os.path.basename(program) - browser_cookie_module_name = 'browser_cookie3' if sys.version_info[0] >= 3 else 'browsercookie' - PYTHON = os.path.basename(os.path.splitext(sys.executable)[0]) - DEFAULT_FORMAT_FIELD = "{id}" - cookie_name = '_calcentral_session' - argparser = argparse.ArgumentParser( - prog=program_name, - usage=None, - description=None, - epilog="""FORMAT fields: + program_name = os.path.basename(program) + browser_cookie_module_name = ( + "browser_cookie3" if sys.version_info[0] >= 3 else "browsercookie" + ) + PYTHON = os.path.basename(os.path.splitext(sys.executable)[0]) + DEFAULT_FORMAT_FIELD = "{id}" + cookie_name = "_calcentral_session" + argparser = argparse.ArgumentParser( + prog=program_name, + usage=None, + description=None, + epilog="""FORMAT fields: {roster_fields} INSTRUCTIONS: @@ -101,80 +150,165 @@ def main(program, *args): (You can also instead try the "Set-Cookie" field in the response headers.) Call this script and pass it all the above information as follows: {python} "{program_name}" --format="{default_format_field}" "{cookie_name}=XXXXX" < "Student-Emails.txt" -""".format(python=PYTHON, cookie_name=cookie_name, roster_fields=" " + ", ".join(ROSTER_FIELDS), default_format_field=DEFAULT_FORMAT_FIELD, program_name=program_name), - parents=[], - formatter_class=argparse.RawTextHelpFormatter, - add_help=True) - server = "junction.berkeley.edu" - server_with_protocol = "https://" + server - argparser.add_argument('--format', required=False, help="the photo file name format string (default: \"%%(default)s\")\n(example: \"%s\")" % ('{id} - {first_name} {last_name} - {student_id} - {email}',), default=DEFAULT_FORMAT_FIELD) - argparser.add_argument('--file', required=False, action='store_true', help="indicates cookie parameter is a file name rather than the cookie string itself") - argparser.add_argument('bcourses_cookie', nargs='?', help="bCourses cookie string (default) or cookie file (if --file is specified)\r\ncontaining the %s cookie for %s" % (repr(cookie_name), server)) - - if 'COLUMNS' not in os.environ: - os.environ['COLUMNS'] = str(128) - parsed_args = argparser.parse_args(args) - format_string = parsed_args.format - bcourses_cookie = parsed_args.bcourses_cookie - emails = sys.stdin - if bcourses_cookie is None: - sys.stderr.write("Automatically extracting %r cookies from the browser..." % (server,)); sys.stderr.flush() - try: - try: browser_cookie_module = __import__(browser_cookie_module_name) - except ImportError: browser_cookie_module = None - if browser_cookie_module is None: - msg = "failed to import {module}; please install it: {python} -m pip install {package}".format(python=PYTHON, module=browser_cookie_module_name, package=browser_cookie_module_name) - raise ImportError(msg) - bcourses_cookie = "; ".join(map(lambda cookie: "%s=%s" % (cookie.name, cookie.value), browser_cookie_module.load(server) if browser_cookie_module_name == 'browser_cookie3' else filter(lambda cookie: cookie.domain == server, browser_cookie_module.load()))) - finally: - if bcourses_cookie: sys.stderr.write(" success! %s" % (bcourses_cookie,)) - else: sys.stderr.write(" failed!") - sys.stderr.write("\n") - elif parsed_args.file: - with open(bcourses_cookie, 'r') as f: - bcourses_cookie = "; ".join(find_cookies_in_cookies_txt(server, f.read())) - else: - if ";" not in bcourses_cookie and " " not in bcourses_cookie and "=" not in bcourses_cookie[:48]: - bcourses_cookie = cookie_name + "=" + bcourses_cookie - sys.stderr.write("WARNING: Cookie string doesn't appear to include %s; assuming you forgot to include it...\n" % (repr(cookie_name + "="),)) - - nthreads = os.getenv('OMP_NUM_THREADS', None) - if not nthreads: nthreads = 16 - nthreads = max(int(nthreads), 1) - - sys.stderr.writelines(["Downloading course info..."]); sys.stderr.flush() - course_info = read_http_response_as_json(urlopen(server_with_protocol + "/api/academics/rosters/canvas/embedded", cookie=bcourses_cookie)) - canvas_course = course_info.get('canvas_course') - if canvas_course is None: - msg = "failed to retrieve course information; please verify the %s cookie is correct: %s" % (repr(cookie_name), repr(bcourses_cookie)) - raise ValueError(msg) - course_id = canvas_course['id'] - course_name = canvas_course['name'] - course_students = course_info['students'] - sys.stderr.writelines([" %s: %s\n" % (course_id, course_name)]); sys.stderr.flush() - roster = dict(map(lambda student: (student['email'], student), course_students)) - q = Queue() - threads = list(map(lambda i: threading.Thread(target=fetcher, args=(q,)), range(nthreads))) - for t in threads: t.setDaemon(True); t.start() - sys.stderr.writelines(["Reading student emails..."]); sys.stderr.flush() - lines = None - if not emails.isatty(): - lines = emails.readlines() - sys.stderr.writelines(["\n"]); sys.stderr.flush() - if lines is None: - sys.stderr.write("No emails specified; downloading entire roster...\n"); sys.stderr.flush() - lines = sorted(roster.keys()) - for line in lines: - found = roster.get(line.strip()) - if found is not None and "photo" in found: - path = os.path.join("%s - %s" % ((list(map(lambda section: section['name'], found['sections'])) + [course_name])[0], course_id), format_string.format(**found)) - sys.stdout.write("> " + path + "\n"); sys.stdout.flush() - q.put((server_with_protocol + found['photo'], bcourses_cookie, path)) - else: - sys.stderr.write("? " + line.strip() + "\n"); sys.stderr.flush() - for t in threads: q.put(None) - for t in threads: t.join() - -if __name__ == '__main__': - import sys - raise SystemExit(main(*sys.argv)) +""".format( + python=PYTHON, + cookie_name=cookie_name, + roster_fields=" " + ", ".join(ROSTER_FIELDS), + default_format_field=DEFAULT_FORMAT_FIELD, + program_name=program_name, + ), + parents=[], + formatter_class=argparse.RawTextHelpFormatter, + add_help=True, + ) + server = "junction.berkeley.edu" + server_with_protocol = "https://" + server + argparser.add_argument( + "--format", + required=False, + help='the photo file name format string (default: "%%(default)s")\n(example: "%s")' + % ("{id} - {first_name} {last_name} - {student_id} - {email}",), + default=DEFAULT_FORMAT_FIELD, + ) + argparser.add_argument( + "--file", + required=False, + action="store_true", + help="indicates cookie parameter is a file name rather than the cookie string itself", + ) + argparser.add_argument( + "bcourses_cookie", + nargs="?", + help="bCourses cookie string (default) or cookie file (if --file is specified)\r\ncontaining the %s cookie for %s" + % (repr(cookie_name), server), + ) + + if "COLUMNS" not in os.environ: + os.environ["COLUMNS"] = str(128) + parsed_args = argparser.parse_args(args) + format_string = parsed_args.format + bcourses_cookie = parsed_args.bcourses_cookie + emails = sys.stdin + if bcourses_cookie is None: + sys.stderr.write( + "Automatically extracting %r cookies from the browser..." % (server,) + ) + sys.stderr.flush() + try: + try: + browser_cookie_module = __import__(browser_cookie_module_name) + except ImportError: + browser_cookie_module = None + if browser_cookie_module is None: + msg = "failed to import {module}; please install it: {python} -m pip install {package}".format( + python=PYTHON, + module=browser_cookie_module_name, + package=browser_cookie_module_name, + ) + raise ImportError(msg) + bcourses_cookie = "; ".join( + map( + lambda cookie: "%s=%s" % (cookie.name, cookie.value), + browser_cookie_module.load(server) + if browser_cookie_module_name == "browser_cookie3" + else filter( + lambda cookie: cookie.domain == server, + browser_cookie_module.load(), + ), + ) + ) + finally: + if bcourses_cookie: + sys.stderr.write(" success! %s" % (bcourses_cookie,)) + else: + sys.stderr.write(" failed!") + sys.stderr.write("\n") + elif parsed_args.file: + with open(bcourses_cookie, "r") as f: + bcourses_cookie = "; ".join(find_cookies_in_cookies_txt(server, f.read())) + else: + if ( + ";" not in bcourses_cookie + and " " not in bcourses_cookie + and "=" not in bcourses_cookie[:48] + ): + bcourses_cookie = cookie_name + "=" + bcourses_cookie + sys.stderr.write( + "WARNING: Cookie string doesn't appear to include %s; assuming you forgot to include it...\n" + % (repr(cookie_name + "="),) + ) + + nthreads = os.getenv("OMP_NUM_THREADS", None) + if not nthreads: + nthreads = 16 + nthreads = max(int(nthreads), 1) + + sys.stderr.writelines(["Downloading course info..."]) + sys.stderr.flush() + course_info = read_http_response_as_json( + urlopen( + server_with_protocol + "/api/academics/rosters/canvas/embedded", + cookie=bcourses_cookie, + ) + ) + canvas_course = course_info.get("canvas_course") + if canvas_course is None: + msg = ( + "failed to retrieve course information; please verify the %s cookie is correct: %s" + % (repr(cookie_name), repr(bcourses_cookie)) + ) + raise ValueError(msg) + course_id = canvas_course["id"] + course_name = canvas_course["name"] + course_students = course_info["students"] + sys.stderr.writelines([" %s: %s\n" % (course_id, course_name)]) + sys.stderr.flush() + roster = dict(map(lambda student: (student["email"], student), course_students)) + q = Queue() + threads = list( + map(lambda i: threading.Thread(target=fetcher, args=(q,)), range(nthreads)) + ) + for t in threads: + t.setDaemon(True) + t.start() + sys.stderr.writelines(["Reading student emails..."]) + sys.stderr.flush() + lines = None + if not emails.isatty(): + lines = emails.readlines() + sys.stderr.writelines(["\n"]) + sys.stderr.flush() + if lines is None: + sys.stderr.write("No emails specified; downloading entire roster...\n") + sys.stderr.flush() + lines = sorted(roster.keys()) + for line in lines: + found = roster.get(line.strip()) + if found is not None and "photo" in found: + path = os.path.join( + "%s - %s" + % ( + ( + list(map(lambda section: section["name"], found["sections"])) + + [course_name] + )[0], + course_id, + ), + format_string.format(**found), + ) + sys.stdout.write("> " + path + "\n") + sys.stdout.flush() + q.put((server_with_protocol + found["photo"], bcourses_cookie, path)) + else: + sys.stderr.write("? " + line.strip() + "\n") + sys.stderr.flush() + for t in threads: + q.put(None) + for t in threads: + t.join() + + +if __name__ == "__main__": + import sys + + raise SystemExit(main(*sys.argv)) diff --git a/server/auth.py b/server/auth.py index 479849c..0f862e3 100644 --- a/server/auth.py +++ b/server/auth.py @@ -1,11 +1,10 @@ from flask import redirect, request, session, url_for -from flask_login import LoginManager, login_required, login_user, logout_user -from flask_oauthlib.client import OAuth, OAuthException -from oauth2client.contrib.flask_util import UserOAuth2 +from flask_login import LoginManager, login_user, logout_user +from flask_oauthlib.client import OAuth from werkzeug import security from server import app -from server.models import db, User, Student, SeatAssignment +from server.models import SeatAssignment, Student, User, db login_manager = LoginManager(app=app) @@ -30,8 +29,6 @@ def get_access_token(token=None): return session.get('ok_token') -google_oauth = UserOAuth2(app) - @login_manager.user_loader def load_user(user_id): return User.query.get(user_id) @@ -73,10 +70,10 @@ def authorized(): return redirect('/seat/{}'.format(seat.seat_id)) elif p['role'] != 'lab assistant': is_staff = True - + if not is_staff: return 'Access denied: {}'.format(request.args.get('error', 'unknown error')) - + user = User.query.filter_by(email=email).one_or_none() if not user: user = User(email=email) diff --git a/server/models.py b/server/models.py index bc4c95b..2603998 100644 --- a/server/models.py +++ b/server/models.py @@ -11,7 +11,6 @@ from server import app db = SQLAlchemy(app=app) - class StringSet(types.TypeDecorator): impl = types.Text @@ -120,10 +119,10 @@ def drop_db(): @app.cli.command('seeddb') def seed_db(): "Seeds database with data" - for seed_exam in seed_exams: - existing = Exam.query.filter_by(offering=seed_exam.offering, name=seed_exam.name).first() - if not existing: - click.echo('Adding seed exam {}...'.format(seed_exam.name)) + for seed_exam in seed_exams: + existing = Exam.query.filter_by(offering=seed_exam.offering, name=seed_exam.name).first() + if not existing: + click.echo('Adding seed exam {}...'.format(seed_exam.name)) db.session.add(seed_exam) db.session.commit() @@ -135,20 +134,20 @@ def reset_db(ctx): ctx.invoke(init_db) ctx.invoke(seed_db) -seed_exams = [ - Exam( - offering=app.config['COURSE'], - name='midterm1', - display_name='Midterm 1', - ), - Exam( - offering=app.config['COURSE'], - name='midterm2', - display_name='Midterm 2', - ), - Exam( - offering=app.config['COURSE'], - name='final', - display_name='Final', - ), +seed_exams = [ + Exam( + offering=app.config['COURSE'], + name='midterm1', + display_name='Midterm 1', + ), + Exam( + offering=app.config['COURSE'], + name='midterm2', + display_name='Midterm 2', + ), + Exam( + offering=app.config['COURSE'], + name='final', + display_name='Final', + ), ] diff --git a/server/views.py b/server/views.py index 79dd2d7..c2a8167 100644 --- a/server/views.py +++ b/server/views.py @@ -1,33 +1,34 @@ import itertools -import os import random import re -import sys +import requests +import sendgrid from apiclient import discovery, errors from flask import abort, redirect, render_template, request, send_file, session, url_for from flask_login import current_user from flask_wtf import FlaskForm -import sendgrid from werkzeug.exceptions import HTTPException from werkzeug.routing import BaseConverter -from wtforms import HiddenField, SelectMultipleField, StringField, SubmitField, TextAreaField, widgets +from wtforms import SelectMultipleField, StringField, SubmitField, TextAreaField, widgets from wtforms.validators import Email, InputRequired, URL from server import app -from server.auth import google_oauth, ok_oauth -from server.models import db, Exam, Room, Seat, SeatAssignment, Student, slug +from server.models import Exam, Room, Seat, SeatAssignment, Student, db, slug name_part = '[^/]+' + class Redirect(HTTPException): code = 302 + def __init__(self, url): self.url = url def get_response(self, environ=None): return redirect(self.url) + class ExamConverter(BaseConverter): regex = name_part + '/' + name_part + '/' + name_part + '/' + name_part @@ -44,15 +45,19 @@ def to_python(self, value): def to_url(self, exam): return exam.offering + '/' + exam.name + app.url_map.converters['exam'] = ExamConverter + class ValidationError(Exception): pass + class MultiCheckboxField(SelectMultipleField): widget = widgets.ListWidget(prefix_label=False) option_widget = widgets.CheckboxInput() + class RoomForm(FlaskForm): display_name = StringField('display_name', [InputRequired()]) sheet_url = StringField('sheet_url', [URL()]) @@ -60,11 +65,12 @@ class RoomForm(FlaskForm): preview_room = SubmitField('preview') create_room = SubmitField('create') + class MultRoomForm(FlaskForm): - rooms = MultiCheckboxField(choices=[('277 Cory', '277 Cory'), - ('145 Dwinelle', '145 Dwinelle'), + rooms = MultiCheckboxField(choices=[('277 Cory', '277 Cory'), + ('145 Dwinelle', '145 Dwinelle'), ('155 Dwinelle', '155 Dwinelle'), - ('10 Evans', '10 Evans'), + ('10 Evans', '10 Evans'), ('100 GPB', '100 GPB'), ('A1 Hearst Field Annex', 'A1 Hearst Field Annex'), ('Hearst Gym', 'Hearst Gym'), @@ -87,23 +93,14 @@ class MultRoomForm(FlaskForm): ]) submit = SubmitField('import') + def read_csv(sheet_url, sheet_range): - m = re.search(r'/spreadsheets/d/([a-zA-Z0-9-_]+)', sheet_url) - if not m: - raise ValidationError('Enter a Google Sheets URL') - spreadsheet_id = m.group(1) - http = google_oauth.http() - discoveryUrl = ('https://sheets.googleapis.com/$discovery/rest?' - 'version=v4') - service = discovery.build('sheets', 'v4', http=http, - discoveryServiceUrl=discoveryUrl) - - try: - result = service.spreadsheets().values().get( - spreadsheetId=spreadsheet_id, range=sheet_range).execute() - except errors.HttpError as e: - raise ValidationError(e._get_reason()) - values = result.get('values', []) + values = requests.post("https://auth.apps.cs61a.org/google/read_spreadsheet", json={ + "url": sheet_url, + "sheet_name": sheet_range, + "client_name": app.config["AUTH_KEY"], + "secret": app.config["AUTH_CLIENT_SECRET"], + }).json() if not values: raise ValidationError('Sheet is empty') @@ -118,6 +115,7 @@ def read_csv(sheet_url, sheet_range): raise ValidationError('Headers must consist of digits and numbers') return headers, rows + def validate_room(exam, room_form): room = Room( exam_id=exam.id, @@ -159,7 +157,7 @@ def validate_room(exam, room_form): raise ValidationError('xy coordinates must be floats') seat.x = x seat.y = y - seat.attributes = { k for k, v in row.items() if v.lower() == 'true' } + seat.attributes = {k for k, v in row.items() if v.lower() == 'true'} room.seats.append(seat) if len(set(seat.name for seat in room.seats)) != len(room.seats): raise ValidationError('Seats are not unique') @@ -167,15 +165,15 @@ def validate_room(exam, room_form): raise ValidationError('Seat coordinates are not unique') return room + @app.route('//rooms/import/') -@google_oauth.required(scopes=['https://www.googleapis.com/auth/spreadsheets.readonly']) def import_room(exam): new_form = RoomForm() choose_form = MultRoomForm() return render_template('new_room.html.j2', exam=exam, new_form=new_form, choose_form=choose_form) + @app.route('//rooms/import/new/', methods=['GET', 'POST']) -@google_oauth.required(scopes=['https://www.googleapis.com/auth/spreadsheets.readonly']) def new_room(exam): new_form = RoomForm() choose_form = MultRoomForm() @@ -191,21 +189,24 @@ def new_room(exam): return redirect(url_for('exam', exam=exam)) return render_template('new_room.html.j2', exam=exam, new_form=new_form, choose_form=choose_form, room=room) + @app.route('//rooms/import/choose/', methods=['GET', 'POST']) -@google_oauth.required(scopes=['https://www.googleapis.com/auth/spreadsheets.readonly']) def mult_new_room(exam): new_form = RoomForm() choose_form = MultRoomForm() if choose_form.validate_on_submit(): for r in choose_form.rooms.data: # add error handling - f = RoomForm(display_name=r,sheet_url='https://docs.google.com/spreadsheets/d/1cHKVheWv2JnHBorbtfZMW_3Sxj9VtGMmAUU2qGJ33-s/edit?usp=sharing',sheet_range=r) + f = RoomForm(display_name=r, + sheet_url='https://docs.google.com/spreadsheets/d/1cHKVheWv2JnHBorbtfZMW_3Sxj9VtGMmAUU2qGJ33-s/edit?usp=sharing', + sheet_range=r) room = validate_room(exam, f) db.session.add(room) db.session.commit() return redirect(url_for('exam', exam=exam)) return render_template('new_room.html.j2', exam=exam, new_form=new_form, choose_form=choose_form) + @app.route('//rooms/update//', methods=['POST']) def update_room(exam, room_name): # ask if want to delete @@ -216,7 +217,8 @@ def update_room(exam, room_name): db.session.commit() return render_template('exam.html.j2', exam=exam) -@app.route('//rooms/delete//', methods=['GET','POST']) + +@app.route('//rooms/delete//', methods=['GET', 'POST']) def delete_room(exam, room_name): # ask if want to delete # if assigned ask if they are sure they want to delete seat assignments @@ -231,11 +233,13 @@ def delete_room(exam, room_name): db.session.commit() return render_template('exam.html.j2', exam=exam) + class StudentForm(FlaskForm): sheet_url = StringField('sheet_url', [URL()]) sheet_range = StringField('sheet_range', [InputRequired()]) submit = SubmitField('import') + def validate_students(exam, form): headers, rows = read_csv(form.sheet_url.data, form.sheet_range.data) if 'email' not in headers: @@ -253,13 +257,13 @@ def validate_students(exam, form): student.name = row.pop('name') student.sid = row.pop('student id', None) or student.sid student.bcourses_id = row.pop('bcourses id', None) or student.bcourses_id - student.wants = { k for k, v in row.items() if v.lower() == 'true' } - student.avoids = { k for k, v in row.items() if v.lower() == 'false' } + student.wants = {k for k, v in row.items() if v.lower() == 'true'} + student.avoids = {k for k, v in row.items() if v.lower() == 'false'} students.append(student) return students + @app.route('//students/import/', methods=['GET', 'POST']) -@google_oauth.required(scopes=['https://www.googleapis.com/auth/spreadsheets.readonly']) def new_students(exam): form = StudentForm() if form.validate_on_submit(): @@ -272,10 +276,12 @@ def new_students(exam): form.sheet_url.errors.append(str(e)) return render_template('new_students.html.j2', exam=exam, form=form) + class DeleteStudentForm(FlaskForm): emails = TextAreaField('emails') submit = SubmitField('delete') + @app.route('//students/delete/', methods=['GET', 'POST']) def delete_students(exam): form = DeleteStudentForm() @@ -294,11 +300,13 @@ def delete_students(exam): did_not_exist.add(email) db.session.commit() return render_template('delete_students.html.j2', - exam=exam, form=form, deleted=deleted, did_not_exist=did_not_exist) + exam=exam, form=form, deleted=deleted, did_not_exist=did_not_exist) + class AssignForm(FlaskForm): submit = SubmitField('assign') + def collect(s, key=lambda x: x): d = {} for x in s: @@ -309,6 +317,7 @@ def collect(s, key=lambda x: x): d[k] = [x] return d + def assign_students(exam): """The strategy: look for students whose requirements are the most restrictive (i.e. have the fewest possible seats). Randomly assign them @@ -327,13 +336,13 @@ def seats_available(preference): return [ seat for seat in seats if all(a in seat.attributes for a in wants) - and all(a not in seat.attributes for a in avoids) + and all(a not in seat.attributes for a in avoids) ] assignments = [] while students: students_by_preference = collect(students, key= - lambda student: (frozenset(student.wants), frozenset(student.avoids))) + lambda student: (frozenset(student.wants), frozenset(student.avoids))) seats_by_preference = { preference: seats_available(preference) for preference in students_by_preference @@ -353,6 +362,7 @@ def seats_available(preference): assignments.append(SeatAssignment(student=student, seat=seat)) return assignments + @app.route('//students/assign/', methods=['GET', 'POST']) def assign(exam): form = AssignForm() @@ -365,6 +375,7 @@ def assign(exam): return redirect(url_for('students', exam=exam)) return render_template('assign.html.j2', exam=exam, form=form) + class EmailForm(FlaskForm): from_email = StringField('from_email', [Email()]) from_name = StringField('from_name', [InputRequired()]) @@ -373,6 +384,7 @@ class EmailForm(FlaskForm): additional_text = TextAreaField('additional_text') submit = SubmitField('send') + def email_students(exam, form): """Emails students in batches of 900""" sg = sendgrid.SendGridAPIClient(api_key=app.config['SENDGRID_API_KEY']) @@ -440,6 +452,7 @@ def email_students(exam, form): assignment.emailed = True db.session.commit() + @app.route('//students/email/', methods=['GET', 'POST']) def email(exam): form = EmailForm() @@ -448,54 +461,65 @@ def email(exam): return redirect(url_for('students', exam=exam)) return render_template('email.html.j2', exam=exam, form=form) + @app.route('/') def index(): return redirect('/' + app.config['COURSE'] + '/' + app.config['EXAM']) + @app.route('/favicon.ico') def favicon(): return send_file('static/img/favicon.ico') + @app.route('/students-template.png') def students_template(): return send_file('static/img/students-template.png') + @app.route('//') def exam(exam): return render_template('exam.html.j2', exam=exam) + @app.route('//help/') def help(exam): return render_template('help.html.j2', exam=exam) + @app.route('//students/photos/', methods=['GET', 'POST']) def new_photos(exam): return render_template('new_photos.html.j2', exam=exam) + @app.route('//rooms//') def room(exam, name): room = Room.query.filter_by(exam_id=exam.id, name=name).first_or_404() seat = request.args.get('seat') return render_template('room.html.j2', exam=exam, room=room, seat=seat) + @app.route('//students/') def students(exam): # TODO load assignment and seat at the same time? students = Student.query.filter_by(exam_id=exam.id).all() return render_template('students.html.j2', exam=exam, students=students) + @app.route('//students//') def student(exam, email): student = Student.query.filter_by(exam_id=exam.id, email=email).first_or_404() return render_template('student.html.j2', exam=exam, student=student) + @app.route('//students//photo') def photo(exam, email): student = Student.query.filter_by(exam_id=exam.id, email=email).first_or_404() - photo_path = '{}/{}/{}.jpeg'.format(app.config['PHOTO_DIRECTORY'], - exam.offering, student.bcourses_id) + photo_path = '{}/{}/{}.jpeg'.format(app.config['PHOTO_DIRECTORY'], + exam.offering, student.bcourses_id) return send_file(photo_path, mimetype='image/jpeg') + @app.route('/seat//') def single_seat(seat_id): seat = Seat.query.filter_by(id=seat_id).first_or_404() From 94b96e3a9b66faaeae8975bae733a0b3fc341963 Mon Sep 17 00:00:00 2001 From: rahularya Date: Mon, 17 Feb 2020 05:05:29 -0800 Subject: [PATCH 03/34] refactored templates, implemented exam selection view --- config.py | 4 +- server/models.py | 5 +++ server/templates/assign.html.j2 | 2 +- server/templates/base.html.j2 | 25 ++--------- server/templates/delete_students.html.j2 | 4 +- server/templates/email.html.j2 | 2 +- server/templates/exam.html.j2 | 2 +- server/templates/exam_base.html.j2 | 57 ++++++++++++++++++++++++ server/templates/help.html.j2 | 4 +- server/templates/new_photos.html.j2 | 4 +- server/templates/new_room.html.j2 | 2 +- server/templates/new_students.html.j2 | 6 +-- server/templates/room.html.j2 | 2 +- server/templates/select_exam.j2 | 19 ++++++++ server/templates/student.html.j2 | 2 +- server/templates/students.html.j2 | 2 +- server/views.py | 4 +- 17 files changed, 104 insertions(+), 42 deletions(-) create mode 100644 server/templates/exam_base.html.j2 create mode 100644 server/templates/select_exam.j2 diff --git a/config.py b/config.py index f28dfee..10ab41c 100644 --- a/config.py +++ b/config.py @@ -29,8 +29,8 @@ PHOTO_DIRECTORY = os.getenv('PHOTO_DIRECTORY') # Used for redirects and auth: /COURSE/EXAM -COURSE = os.getenv('COURSE', 'cal/test/fa18') -EXAM = os.getenv('EXAM') +COURSE = os.getenv('COURSE', 'cal/cs61a/sp20') +EXAM = os.getenv('EXAM', "midterm1") TEST_LOGIN = os.getenv('TEST_LOGIN') diff --git a/server/models.py b/server/models.py index 2603998..41d4518 100644 --- a/server/models.py +++ b/server/models.py @@ -150,4 +150,9 @@ def reset_db(ctx): name='final', display_name='Final', ), + Exam( + offering="cal/eecs16a/sp20", + name='test', + display_name='Test', + ), ] diff --git a/server/templates/assign.html.j2 b/server/templates/assign.html.j2 index 0b2e46a..422920c 100644 --- a/server/templates/assign.html.j2 +++ b/server/templates/assign.html.j2 @@ -1,4 +1,4 @@ -{% extends 'base.html.j2' %} +{% extends 'exam_base.html.j2' %} {% import 'macros.html.j2' as macros with context %} {% block body %} diff --git a/server/templates/base.html.j2 b/server/templates/base.html.j2 index c69f808..527791f 100644 --- a/server/templates/base.html.j2 +++ b/server/templates/base.html.j2 @@ -11,7 +11,7 @@ - {% block title %}{{ exam.display_name }}{% endblock %} + {% block title %}{{ title }}{% endblock %} @@ -19,31 +19,12 @@
diff --git a/server/templates/delete_students.html.j2 b/server/templates/delete_students.html.j2 index 052258b..572b98d 100644 --- a/server/templates/delete_students.html.j2 +++ b/server/templates/delete_students.html.j2 @@ -1,4 +1,4 @@ -{% extends 'base.html.j2' %} +{% extends 'exam_base.html.j2' %} {% import 'macros.html.j2' as macros with context %} {% block body %} @@ -15,7 +15,7 @@
{% endcall %} -
+
Deleted:
    {% for student in deleted|sort %} diff --git a/server/templates/email.html.j2 b/server/templates/email.html.j2 index 391249e..0e45315 100644 --- a/server/templates/email.html.j2 +++ b/server/templates/email.html.j2 @@ -1,4 +1,4 @@ -{% extends 'base.html.j2' %} +{% extends 'exam_base.html.j2' %} {% import 'macros.html.j2' as macros with context %} {% block body %} diff --git a/server/templates/exam.html.j2 b/server/templates/exam.html.j2 index 75f1bd1..873496f 100644 --- a/server/templates/exam.html.j2 +++ b/server/templates/exam.html.j2 @@ -1,4 +1,4 @@ -{% extends 'base.html.j2' %} +{% extends 'exam_base.html.j2' %} {% import 'macros.html.j2' as macros with context %} {% block body %} diff --git a/server/templates/exam_base.html.j2 b/server/templates/exam_base.html.j2 new file mode 100644 index 0000000..c69f808 --- /dev/null +++ b/server/templates/exam_base.html.j2 @@ -0,0 +1,57 @@ + + + + + + + + + + + + + {% block title %}{{ exam.display_name }}{% endblock %} + + + + +
    +
    + +
    + +
    + {% block body %}{% endblock %} +
    +
    + + + + diff --git a/server/templates/help.html.j2 b/server/templates/help.html.j2 index 5feede3..74aab7e 100644 --- a/server/templates/help.html.j2 +++ b/server/templates/help.html.j2 @@ -1,11 +1,11 @@ -{% extends 'base.html.j2' %} +{% extends 'exam_base.html.j2' %} {% block body %}

    Using the app

    roomRooms.

    -

    There are two options: import a room via Google Sheet or choose from our available rooms.

    +

    There are two options: import a room via Google Sheet or choose from our available rooms.

    Room data is entered from a Google Sheet. Copy a sheet from the room master sheet. If it does not exist, you can try looking through the rooms used in the past years.

    If it cannot be found, please make a sheet for the room and add it to the master.

    diff --git a/server/templates/new_photos.html.j2 b/server/templates/new_photos.html.j2 index 2870242..a609c2e 100644 --- a/server/templates/new_photos.html.j2 +++ b/server/templates/new_photos.html.j2 @@ -1,10 +1,10 @@ -{% extends 'base.html.j2' %} +{% extends 'exam_base.html.j2' %} {% block body %}

    🚧🚧🚧 Under Construction 🚧🚧🚧

    -

    This page is still under construction; however, you may follow these instructions to have your photos added to your seating app. Download this script and follow the instructions. You should send the slack a zip of the photos in the format of bcoursesID.jpeg (make sure it's not .jpg!). Photos will be added to the seating app shortly.

    +

    This page is still under construction; however, you may follow these instructions to have your photos added to your seating app. Download this script and follow the instructions. You should send the slack a zip of the photos in the format of bcoursesID.jpeg (make sure it's not .jpg!). Photos will be added to the seating app shortly.

    {% endblock %} diff --git a/server/templates/new_room.html.j2 b/server/templates/new_room.html.j2 index 8ece464..8565d75 100644 --- a/server/templates/new_room.html.j2 +++ b/server/templates/new_room.html.j2 @@ -1,4 +1,4 @@ -{% extends 'base.html.j2' %} +{% extends 'exam_base.html.j2' %} {% import 'macros.html.j2' as macros with context %} {% block body %} diff --git a/server/templates/new_students.html.j2 b/server/templates/new_students.html.j2 index cb7f4fd..64bb608 100644 --- a/server/templates/new_students.html.j2 +++ b/server/templates/new_students.html.j2 @@ -1,4 +1,4 @@ -{% extends 'base.html.j2' %} +{% extends 'exam_base.html.j2' %} {% import 'macros.html.j2' as macros with context %} {% block body %} @@ -41,7 +41,7 @@

    To import students, create a Google spreadsheet with the columns "Name", "Student ID", "Email", and "bCourses ID". The remaining columns are arbitrary attributes - (ex: LEFTY, RIGHTY, BROKEN) that express student preferences.

    + (ex: LEFTY, RIGHTY, BROKEN) that express student preferences.

    For example, if a student has LEFTY=TRUE, they will be assigned a seat with the LEFTY attribute. If a student has LEFTY=FALSE, they will be assigned a seat @@ -57,7 +57,7 @@

    You can add students to the spreadsheet and import them again later. Duplicates will be merged.

    -
+
{% endcall %} {% endblock %} diff --git a/server/templates/room.html.j2 b/server/templates/room.html.j2 index 147d3b9..4037333 100644 --- a/server/templates/room.html.j2 +++ b/server/templates/room.html.j2 @@ -1,4 +1,4 @@ -{% extends 'base.html.j2' %} +{% extends 'exam_base.html.j2' %} {% import 'macros.html.j2' as macros with context %} {% block title %}{{ room.display_name }} | {{ super() }}{% endblock %} diff --git a/server/templates/select_exam.j2 b/server/templates/select_exam.j2 new file mode 100644 index 0000000..18f32f9 --- /dev/null +++ b/server/templates/select_exam.j2 @@ -0,0 +1,19 @@ +{% extends 'base.html.j2' %} +{% import 'macros.html.j2' as macros with context %} + +{% block body %} +
+
+

Exams

+ +
+
+{% endblock %} diff --git a/server/templates/student.html.j2 b/server/templates/student.html.j2 index abd6708..66be4bc 100644 --- a/server/templates/student.html.j2 +++ b/server/templates/student.html.j2 @@ -1,4 +1,4 @@ -{% extends 'base.html.j2' %} +{% extends 'exam_base.html.j2' %} {% import 'macros.html.j2' as macros with context %} {% block title %}{{ student.name }} | {{ super() }}{% endblock %} diff --git a/server/templates/students.html.j2 b/server/templates/students.html.j2 index a58427b..478268e 100644 --- a/server/templates/students.html.j2 +++ b/server/templates/students.html.j2 @@ -1,4 +1,4 @@ -{% extends 'base.html.j2' %} +{% extends 'exam_base.html.j2' %} {% import 'macros.html.j2' as macros with context %} {% block title %}Students | {{ super() }}{% endblock %} diff --git a/server/views.py b/server/views.py index c2a8167..81b0c2e 100644 --- a/server/views.py +++ b/server/views.py @@ -4,7 +4,6 @@ import requests import sendgrid -from apiclient import discovery, errors from flask import abort, redirect, render_template, request, send_file, session, url_for from flask_login import current_user from flask_wtf import FlaskForm @@ -464,7 +463,8 @@ def email(exam): @app.route('/') def index(): - return redirect('/' + app.config['COURSE'] + '/' + app.config['EXAM']) + exams = Exam.query.filter(Exam.offering=="cal/cs61a/sp20") + return render_template("select_exam.j2", title="CS 61A Exam Seating", exams=exams) @app.route('/favicon.ico') From 3a06be494fb0c808cd040b201c16f927cda54d65 Mon Sep 17 00:00:00 2001 From: rahularya Date: Mon, 17 Feb 2020 05:08:06 -0800 Subject: [PATCH 04/34] added link back to homepage --- server/templates/exam_base.html.j2 | 1 + 1 file changed, 1 insertion(+) diff --git a/server/templates/exam_base.html.j2 b/server/templates/exam_base.html.j2 index c69f808..11b070f 100644 --- a/server/templates/exam_base.html.j2 +++ b/server/templates/exam_base.html.j2 @@ -42,6 +42,7 @@ {% endfor %} Students + Exams Help From 0d0df04363755b3fcee792855c88b9b41bacf324 Mon Sep 17 00:00:00 2001 From: rahularya Date: Mon, 17 Feb 2020 17:35:48 -0800 Subject: [PATCH 05/34] ability to create new exams --- server/templates/new_exam.html.j2 | 40 +++++++++++++++++++++++++++++++ server/templates/select_exam.j2 | 4 ++++ server/views.py | 22 +++++++++++++++++ 3 files changed, 66 insertions(+) create mode 100644 server/templates/new_exam.html.j2 diff --git a/server/templates/new_exam.html.j2 b/server/templates/new_exam.html.j2 new file mode 100644 index 0000000..a1bdb6e --- /dev/null +++ b/server/templates/new_exam.html.j2 @@ -0,0 +1,40 @@ +{% extends 'base.html.j2' %} +{% import 'macros.html.j2' as macros with context %} + +{% block body %} +{% call macros.form(form) %} + +
+
+
+ {{ form.name(class="mdl-textfield__input", type="Exam Name", id="name") }} + +
+
+ {% if form.name.errors %} + {% for error in form.name.errors %} + {{ form.name.errors }} + {% endfor %} + {% endif %} +
+
+
+
+ {{ form.display_name(class="mdl-textfield__input", type="Exam Display Name", id="displayname") }} + +
+
+ {% if form.display_name.errors %} + {% for error in form.display_name.errors %} + {{ form.display_name.errors }} + {% endfor %} + {% endif %} +
+
+
+ {{ form.submit(class="mdl-button mdl-js-button mdl-button--raised") }} +
+
+ +{% endcall %} +{% endblock %} diff --git a/server/templates/select_exam.j2 b/server/templates/select_exam.j2 index 18f32f9..78dbce8 100644 --- a/server/templates/select_exam.j2 +++ b/server/templates/select_exam.j2 @@ -15,5 +15,9 @@ {% endfor %} + {% endblock %} diff --git a/server/views.py b/server/views.py index 81b0c2e..4fa33ec 100644 --- a/server/views.py +++ b/server/views.py @@ -476,6 +476,28 @@ def favicon(): def students_template(): return send_file('static/img/students-template.png') +class ExamForm(FlaskForm): + display_name = StringField('display_name', [InputRequired()], render_kw={"placeholder": "Midterm 1"}) + name = StringField('name', [InputRequired()], render_kw={"placeholder": "midterm1"}) + submit = SubmitField('create') + + def validate_name(form, field): + if " " in field.data or field.data != field.data.lower(): + from wtforms.validators import ValidationError + raise ValidationError('Exam name must be all lowercase with no spaces') + + +@app.route("/new/", methods=["GET", "POST"]) +def new_exam(): + form = ExamForm() + if form.validate_on_submit(): + exam = Exam(offering=app.config["COURSE"], name=form.name.data, display_name=form.display_name.data) + db.session.add(exam) + db.session.commit() + + return redirect(url_for("index")) + return render_template("new_exam.html.j2", title="CS 61A Exam Seating", form=form) + @app.route('//') def exam(exam): From 7c528bb90b514400646f4bd350fcdd59546e74b7 Mon Sep 17 00:00:00 2001 From: rahularya Date: Mon, 17 Feb 2020 17:41:23 -0800 Subject: [PATCH 06/34] added ability to delete exams --- server/templates/select_exam.j2 | 1 + server/views.py | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/server/templates/select_exam.j2 b/server/templates/select_exam.j2 index 78dbce8..c47ee56 100644 --- a/server/templates/select_exam.j2 +++ b/server/templates/select_exam.j2 @@ -11,6 +11,7 @@ {{ exam.display_name }} +
clear
{% endfor %} diff --git a/server/views.py b/server/views.py index 4fa33ec..e8b5f31 100644 --- a/server/views.py +++ b/server/views.py @@ -499,6 +499,14 @@ def new_exam(): return render_template("new_exam.html.j2", title="CS 61A Exam Seating", form=form) +@app.route("//delete/", methods=["GET", "POST"]) +def delete_exam(exam): + db.session.delete(exam) + db.session.commit() + + return redirect(url_for("index")) + + @app.route('//') def exam(exam): return render_template('exam.html.j2', exam=exam) From a728c0e49ffc00c2421a647840799d28de5cdf5b Mon Sep 17 00:00:00 2001 From: rahularya Date: Mon, 17 Feb 2020 21:48:03 -0800 Subject: [PATCH 07/34] authenticate properly --- config.py | 4 -- server/auth.py | 14 +++--- server/models.py | 6 +-- server/templates/exam_base.html.j2 | 2 +- server/templates/select_exam.j2 | 2 +- server/views.py | 79 ++++++++++++++++++++++++------ 6 files changed, 76 insertions(+), 31 deletions(-) diff --git a/config.py b/config.py index 10ab41c..62fe7dc 100644 --- a/config.py +++ b/config.py @@ -28,10 +28,6 @@ PHOTO_DIRECTORY = os.getenv('PHOTO_DIRECTORY') -# Used for redirects and auth: /COURSE/EXAM -COURSE = os.getenv('COURSE', 'cal/cs61a/sp20') -EXAM = os.getenv('EXAM', "midterm1") - TEST_LOGIN = os.getenv('TEST_LOGIN') # Secret key for signing cookies diff --git a/server/auth.py b/server/auth.py index 0f862e3..7c808ed 100644 --- a/server/auth.py +++ b/server/auth.py @@ -5,6 +5,9 @@ from server import app from server.models import SeatAssignment, Student, User, db +from server.views import get_endpoint + +AUTHORIZED_ROLES = ["instructor", "staff", "grader"] login_manager = LoginManager(app=app) @@ -55,11 +58,11 @@ def authorized(): for p in info['participations']: if is_staff: break - if p['course']['offering'] != app.config['COURSE']: + if p['course']['offering'] != get_endpoint(): continue if p['role'] == 'student': student = Student.query.filter_by(email=email).join(Student.exam) \ - .filter_by(offering=app.config['COURSE'], + .filter_by(offering=get_endpoint(), name=app.config['EXAM']).one_or_none() if not student: return 'Your email is not registered. Please contact the course staff.' @@ -68,7 +71,7 @@ def authorized(): if not seat: return 'No seat found. Please contact the course staff.' return redirect('/seat/{}'.format(seat.seat_id)) - elif p['role'] != 'lab assistant': + elif p['role'] in AUTHORIZED_ROLES: is_staff = True if not is_staff: @@ -77,9 +80,7 @@ def authorized(): user = User.query.filter_by(email=email).one_or_none() if not user: user = User(email=email) - user.offerings = [p['course']['offering'] for p in info['participations']] - if email == app.config['TEST_LOGIN']: - user.offerings.append(app.config['COURSE']) + user.offerings = [p['course']['offering'] for p in info['participations'] if p["role"] in AUTHORIZED_ROLES] db.session.add(user) db.session.commit() @@ -88,6 +89,7 @@ def authorized(): after_login = session.pop('after_login', None) or url_for('index') return redirect(after_login) + @app.route('/logout/') def logout(): session.clear() diff --git a/server/models.py b/server/models.py index 41d4518..a8da71b 100644 --- a/server/models.py +++ b/server/models.py @@ -136,17 +136,17 @@ def reset_db(ctx): seed_exams = [ Exam( - offering=app.config['COURSE'], + offering="cal/cs61a/sp20", name='midterm1', display_name='Midterm 1', ), Exam( - offering=app.config['COURSE'], + offering="cal/cs61a/sp20", name='midterm2', display_name='Midterm 2', ), Exam( - offering=app.config['COURSE'], + offering="cal/cs61a/sp20", name='final', display_name='Final', ), diff --git a/server/templates/exam_base.html.j2 b/server/templates/exam_base.html.j2 index 11b070f..681487c 100644 --- a/server/templates/exam_base.html.j2 +++ b/server/templates/exam_base.html.j2 @@ -42,7 +42,7 @@ {% endfor %} Students - Exams + Other Exams Help diff --git a/server/templates/select_exam.j2 b/server/templates/select_exam.j2 index c47ee56..09f00d0 100644 --- a/server/templates/select_exam.j2 +++ b/server/templates/select_exam.j2 @@ -16,7 +16,7 @@ {% endfor %} - diff --git a/server/views.py b/server/views.py index e8b5f31..07aa902 100644 --- a/server/views.py +++ b/server/views.py @@ -17,6 +17,32 @@ name_part = '[^/]+' +DOMAIN_COURSES = {} +COURSE_ENDPOINTS = {} + + +def get_course(domain=None): + if not domain: + domain = request.headers["HOST"] + if domain not in DOMAIN_COURSES: + DOMAIN_COURSES[domain] = requests.post("https://auth.apps.cs61a.org/domains/get_course", json={ + "domain": domain + }).json() + return DOMAIN_COURSES[domain] + + +def get_endpoint(course=None): + if not course: + course = get_course() + if course not in COURSE_ENDPOINTS: + COURSE_ENDPOINTS[course] = requests.post(f"https://auth.apps.cs61a.org/api/{course}/get_endpoint").json() + return COURSE_ENDPOINTS[course] + + +def format_coursecode(course): + m = re.match(r"([a-z]+)([0-9]+[a-z]?)", course) + return m and (m.group(1) + " " + m.group(2)).upper() + class Redirect(HTTPException): code = 302 @@ -32,12 +58,10 @@ class ExamConverter(BaseConverter): regex = name_part + '/' + name_part + '/' + name_part + '/' + name_part def to_python(self, value): - if not current_user.is_authenticated: + offering, name = value.rsplit('/', 1) + if not current_user.is_authenticated or offering not in current_user.offerings: session['after_login'] = request.url raise Redirect(url_for('login')) - offering, name = value.rsplit('/', 1) - if offering not in current_user.offerings: - abort(404) exam = Exam.query.filter_by(offering=offering, name=name).first_or_404() return exam @@ -45,7 +69,23 @@ def to_url(self, exam): return exam.offering + '/' + exam.name +class OfferingConverter(BaseConverter): + regex = name_part + '/' + name_part + '/' + name_part + + def to_python(self, offering): + if offering != get_endpoint(): + abort(404) + if not current_user.is_authenticated or offering not in current_user.offerings: + session['after_login'] = request.url + raise Redirect(url_for('login')) + return offering + + def to_url(self, offering): + return offering + + app.url_map.converters['exam'] = ExamConverter +app.url_map.converters['offering'] = OfferingConverter class ValidationError(Exception): @@ -242,9 +282,9 @@ class StudentForm(FlaskForm): def validate_students(exam, form): headers, rows = read_csv(form.sheet_url.data, form.sheet_range.data) if 'email' not in headers: - raise Validation('Missing "email" column') + raise ValidationError('Missing "email" column') elif 'name' not in headers: - raise Validation('Missing "name" column') + raise ValidationError('Missing "name" column') students = [] for row in rows: email = row.pop('email') @@ -435,7 +475,7 @@ def email_students(exam, form): {}/seat/-seatid-/ {} -'''.format(exam.display_name, app.config['DOMAIN'], form.additional_text.data) +'''.format(exam.display_name, request.url_root, form.additional_text.data) }, ], } @@ -461,10 +501,17 @@ def email(exam): return render_template('email.html.j2', exam=exam, form=form) -@app.route('/') +@app.route("/") def index(): - exams = Exam.query.filter(Exam.offering=="cal/cs61a/sp20") - return render_template("select_exam.j2", title="CS 61A Exam Seating", exams=exams) + return redirect(url_for("offering", offering=get_endpoint())) + + +@app.route('//') +def offering(offering): + if offering not in current_user.offerings: + abort(401) + exams = Exam.query.filter(Exam.offering==offering) + return render_template("select_exam.j2", title="{} Exam Seating".format(format_coursecode(get_course())), exams=exams, offering=offering) @app.route('/favicon.ico') @@ -487,16 +534,16 @@ def validate_name(form, field): raise ValidationError('Exam name must be all lowercase with no spaces') -@app.route("/new/", methods=["GET", "POST"]) -def new_exam(): +@app.route("//new/", methods=["GET", "POST"]) +def new_exam(offering): form = ExamForm() if form.validate_on_submit(): - exam = Exam(offering=app.config["COURSE"], name=form.name.data, display_name=form.display_name.data) + exam = Exam(offering=offering, name=form.name.data, display_name=form.display_name.data) db.session.add(exam) db.session.commit() - return redirect(url_for("index")) - return render_template("new_exam.html.j2", title="CS 61A Exam Seating", form=form) + return redirect(url_for("offering", offering=offering)) + return render_template("new_exam.html.j2", title="{} Exam Seating".format(format_coursecode(get_course())), form=form) @app.route("//delete/", methods=["GET", "POST"]) @@ -504,7 +551,7 @@ def delete_exam(exam): db.session.delete(exam) db.session.commit() - return redirect(url_for("index")) + return redirect(url_for("offering", offering=exam.offering)) @app.route('//') From 126726e95088c1d6dce0841b48794ecfde7b736f Mon Sep 17 00:00:00 2001 From: rahularya Date: Mon, 17 Feb 2020 22:09:16 -0800 Subject: [PATCH 08/34] Ability to choose which exam is live. --- server/auth.py | 9 +++++---- server/models.py | 6 ++++++ server/templates/{select_exam.j2 => offering.j2} | 1 + server/views.py | 15 +++++++++++++-- 4 files changed, 25 insertions(+), 6 deletions(-) rename server/templates/{select_exam.j2 => offering.j2} (81%) diff --git a/server/auth.py b/server/auth.py index 7c808ed..aac1a25 100644 --- a/server/auth.py +++ b/server/auth.py @@ -4,7 +4,7 @@ from werkzeug import security from server import app -from server.models import SeatAssignment, Student, User, db +from server.models import SeatAssignment, Student, User, db, Exam from server.views import get_endpoint AUTHORIZED_ROLES = ["instructor", "staff", "grader"] @@ -61,9 +61,10 @@ def authorized(): if p['course']['offering'] != get_endpoint(): continue if p['role'] == 'student': - student = Student.query.filter_by(email=email).join(Student.exam) \ - .filter_by(offering=get_endpoint(), - name=app.config['EXAM']).one_or_none() + active_exam = Exam.query.filter_by(is_active=True).one_or_none() + if active_exam is None: + return "No exams are currently active." + student = Student.query.filter_by(email=email, exam=active_exam).one_or_none() if not student: return 'Your email is not registered. Please contact the course staff.' if student: diff --git a/server/models.py b/server/models.py index a8da71b..9471f42 100644 --- a/server/models.py +++ b/server/models.py @@ -35,6 +35,7 @@ class Exam(db.Model): offering = db.Column(db.String(255), nullable=False, index=True) name = db.Column(db.String(255), nullable=False, index=True) display_name = db.Column(db.String(255), nullable=False) + is_active = db.Column(db.BOOLEAN, nullable=False) class Room(db.Model): __tablename__ = 'rooms' @@ -126,6 +127,7 @@ def seed_db(): db.session.add(seed_exam) db.session.commit() + @app.cli.command('resetdb') @click.pass_context def reset_db(ctx): @@ -139,20 +141,24 @@ def reset_db(ctx): offering="cal/cs61a/sp20", name='midterm1', display_name='Midterm 1', + is_active=False, ), Exam( offering="cal/cs61a/sp20", name='midterm2', display_name='Midterm 2', + is_active=False, ), Exam( offering="cal/cs61a/sp20", name='final', display_name='Final', + is_active=False, ), Exam( offering="cal/eecs16a/sp20", name='test', display_name='Test', + is_active=False, ), ] diff --git a/server/templates/select_exam.j2 b/server/templates/offering.j2 similarity index 81% rename from server/templates/select_exam.j2 rename to server/templates/offering.j2 index 09f00d0..cf4dfc7 100644 --- a/server/templates/select_exam.j2 +++ b/server/templates/offering.j2 @@ -8,6 +8,7 @@
    {% for exam in exams %}
  • +
    {{ "star" if exam.is_active else "star_border" }}
    {{ exam.display_name }} diff --git a/server/views.py b/server/views.py index 07aa902..5d70ca5 100644 --- a/server/views.py +++ b/server/views.py @@ -511,7 +511,7 @@ def offering(offering): if offering not in current_user.offerings: abort(401) exams = Exam.query.filter(Exam.offering==offering) - return render_template("select_exam.j2", title="{} Exam Seating".format(format_coursecode(get_course())), exams=exams, offering=offering) + return render_template("offering.j2", title="{} Exam Seating".format(format_coursecode(get_course())), exams=exams, offering=offering) @app.route('/favicon.ico') @@ -538,7 +538,8 @@ def validate_name(form, field): def new_exam(offering): form = ExamForm() if form.validate_on_submit(): - exam = Exam(offering=offering, name=form.name.data, display_name=form.display_name.data) + Exam.query.filter_by(offering=offering).update({"is_active": False}) + exam = Exam(offering=offering, name=form.name.data, display_name=form.display_name.data, is_active=True) db.session.add(exam) db.session.commit() @@ -554,6 +555,16 @@ def delete_exam(exam): return redirect(url_for("offering", offering=exam.offering)) +@app.route("//toggle/", methods=["GET", "POST"]) +def toggle_exam(exam): + if exam.is_active: + exam.is_active = False + else: + Exam.query.filter_by(offering=exam.offering).update({"is_active": False}) + exam.is_active = True + db.session.commit() + return redirect(url_for("offering", offering=exam.offering)) + @app.route('//') def exam(exam): return render_template('exam.html.j2', exam=exam) From 138fec2f7bf5d2208b3154a597639a4d0ae79492 Mon Sep 17 00:00:00 2001 From: rahularya Date: Mon, 17 Feb 2020 22:20:20 -0800 Subject: [PATCH 09/34] pin dep --- requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements.txt b/requirements.txt index 09b50b0..99e98a7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,3 +9,4 @@ flask-wtf natsort pymysql sendgrid +Werkzeug~=0.16.1 From c3790b2c094ab51f4b3efb8e2fa401624fd5e3e0 Mon Sep 17 00:00:00 2001 From: rahularya Date: Mon, 17 Feb 2020 22:22:10 -0800 Subject: [PATCH 10/34] remove f-string --- server/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/views.py b/server/views.py index 5d70ca5..00099da 100644 --- a/server/views.py +++ b/server/views.py @@ -35,7 +35,7 @@ def get_endpoint(course=None): if not course: course = get_course() if course not in COURSE_ENDPOINTS: - COURSE_ENDPOINTS[course] = requests.post(f"https://auth.apps.cs61a.org/api/{course}/get_endpoint").json() + COURSE_ENDPOINTS[course] = requests.post("https://auth.apps.cs61a.org/api/{}/get_endpoint".format(course)).json() return COURSE_ENDPOINTS[course] From f4aca918f91605d4313a2fd1b088b989f08c23dc Mon Sep 17 00:00:00 2001 From: rahularya Date: Tue, 18 Feb 2020 04:26:25 -0800 Subject: [PATCH 11/34] added photo upload functionality --- config.py | 4 +-- server/templates/macros.html.j2 | 2 +- server/templates/new_photos.html.j2 | 56 +++++++++++++++++++++++++---- server/views.py | 27 +++++++++++--- 4 files changed, 76 insertions(+), 13 deletions(-) diff --git a/config.py b/config.py index 62fe7dc..f58e666 100644 --- a/config.py +++ b/config.py @@ -24,9 +24,9 @@ # Email setup. Domain environment is for link in email. SENDGRID_API_KEY = os.getenv('SENDGRID_API_KEY') -DOMAIN = os.getenv('DOMAIN', 'https://seating.test.org') -PHOTO_DIRECTORY = os.getenv('PHOTO_DIRECTORY') +# Must be an absolute path +PHOTO_DIRECTORY = os.getenv('PHOTO_DIRECTORY', os.path.join(BASE_DIR, "storage")) TEST_LOGIN = os.getenv('TEST_LOGIN') diff --git a/server/templates/macros.html.j2 b/server/templates/macros.html.j2 index 8d36755..4f1b381 100644 --- a/server/templates/macros.html.j2 +++ b/server/templates/macros.html.j2 @@ -1,5 +1,5 @@ {% macro form(form) %} -
    + {{ form.hidden_tag() }} {{ caller() }}
    diff --git a/server/templates/new_photos.html.j2 b/server/templates/new_photos.html.j2 index a609c2e..668f820 100644 --- a/server/templates/new_photos.html.j2 +++ b/server/templates/new_photos.html.j2 @@ -1,10 +1,54 @@ {% extends 'exam_base.html.j2' %} +{% import 'macros.html.j2' as macros with context %} {% block body %} -
    -
    -

    🚧🚧🚧 Under Construction 🚧🚧🚧

    -

    This page is still under construction; however, you may follow these instructions to have your photos added to your seating app. Download this script and follow the instructions. You should send the slack a zip of the photos in the format of bcoursesID.jpeg (make sure it's not .jpg!). Photos will be added to the seating app shortly.

    -
    -
    +{% call macros.form(form) %} +
    +

    Photos Upload

    +

    First, download this script + and follow the instructions. Zip up the photos in the format of bcoursesID.jpeg (make sure it's not .jpg!). + Then upload that zip here. Photos may be cleared at the end of the semester, or as space constraints + dictate.

    +
    +
    + +
    + Upload + {{ form.file(id="uploadButton", accept=".zip") }} +
    +
    + {{ form.submit(class="mdl-button mdl-js-button mdl-button--raised") }} +
    +
    + + + +{% endcall %} {% endblock %} diff --git a/server/views.py b/server/views.py index 00099da..9d33c32 100644 --- a/server/views.py +++ b/server/views.py @@ -1,6 +1,8 @@ import itertools +import os import random import re +import zipfile import requests import sendgrid @@ -9,7 +11,8 @@ from flask_wtf import FlaskForm from werkzeug.exceptions import HTTPException from werkzeug.routing import BaseConverter -from wtforms import SelectMultipleField, StringField, SubmitField, TextAreaField, widgets +from werkzeug.utils import secure_filename +from wtforms import SelectMultipleField, StringField, SubmitField, TextAreaField, widgets, FileField from wtforms.validators import Email, InputRequired, URL from server import app @@ -575,9 +578,26 @@ def help(exam): return render_template('help.html.j2', exam=exam) +class PhotosForm(FlaskForm): + file = FileField("file", [InputRequired()]) + submit = SubmitField('Submit') + + @app.route('//students/photos/', methods=['GET', 'POST']) def new_photos(exam): - return render_template('new_photos.html.j2', exam=exam) + form = PhotosForm() + if form.validate_on_submit(): + f = form.file.data + zf = zipfile.ZipFile(f, mode="r") + for name in zf.namelist(): + if name.endswith("/"): + continue + secure_name = secure_filename(name) + path = os.path.join(app.config["PHOTO_DIRECTORY"], exam.offering, secure_name) + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "wb+") as g: + g.write(zf.open(name, "r").read()) + return render_template('new_photos.html.j2', exam=exam, form=form) @app.route('//rooms//') @@ -603,8 +623,7 @@ def student(exam, email): @app.route('//students//photo') def photo(exam, email): student = Student.query.filter_by(exam_id=exam.id, email=email).first_or_404() - photo_path = '{}/{}/{}.jpeg'.format(app.config['PHOTO_DIRECTORY'], - exam.offering, student.bcourses_id) + photo_path = os.path.join(app.config['PHOTO_DIRECTORY'], exam.offering, student.bcourses_id) + ".jpeg" return send_file(photo_path, mimetype='image/jpeg') From 2bd0024749e367027440fecf4a18c7c815595c7d Mon Sep 17 00:00:00 2001 From: rahularya Date: Tue, 18 Feb 2020 04:32:45 -0800 Subject: [PATCH 12/34] update README --- README.md | 27 +++++++++------------------ 1 file changed, 9 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 57bc311..47b61bb 100644 --- a/README.md +++ b/README.md @@ -12,13 +12,12 @@ and students with the appropriate roles. ## Usage (Admin TAs for Courses) -It's janky. Many steps involve directly poking the database. The only way to +It's janky. The only way to correct errors is to manually edit the database, so be careful. In summary, setting up the seating chart involves these steps: -1. **Create an exam** (ex. Midterm 1 or Final). Or at least in the future you will be able to. -For now, contact the slack to have your exam created for you if you did not set up the app. -This step is already done for you if you can successfully view the seating app. +0. **Register your course** on [auth.apps.cs61a.org], specifying your OKPy endpoint, and adding your desired domain for the seating app. Then contact us to activate the seating app for that domain. +1. **Create an exam** (ex. Midterm 1 or Final). 2. **Add rooms.** Choose your rooms from our selection or import your own custom room. 3. **Import students.** Customize your student preferences (left seat, front/back, buildings, etc.) 4. **Assign! Then email!** @@ -57,7 +56,7 @@ you adding the sheet to the [master doc](https://drive.google.com/open?id=1cHKVh To import students, create a Google spreadsheet with the columns "Name", "Student ID", "Email", and "bCourses ID". The remaining columns are arbitrary attributes -(ex: LEFTY, RIGHTY, BROKEN) that express student preferences. +(ex: LEFTY, RIGHTY, BROKEN) that express student preferences. This spreadsheet must be shared with the 61A service account [secure-links@ok-server.iam.gserviceaccount.com](mailto:secure-links@ok-server.iam.gserviceaccount.com). For example, if a student has LEFTY=TRUE, they will be assigned a seat with the LEFTY attribute. If a student has LEFTY=FALSE, they will be assigned a seat @@ -117,8 +116,6 @@ cheaters. Viewing full seating charts requires logging in as a TA or tutor through Ok. -Importing spreadsheets requires a separate Google OAuth login. - All paths at an exam route (e.g. `/cal/cs61a/fa17/midterm1`) require a proper staff login. @@ -127,14 +124,11 @@ a room's full seating chart without displaying any student info or info about seat assignments. When a student attempts to log in, they will be redirected to their assigned -seat page if it exists. This only works for the current COURSE and EXAM as -set in the environment variables. +seat page if it exists. ### Creating exams -Create an exam by adding a row to the `exams` table. The exam that the home page -redirects to is hardcoded, so you may want to change that too. In the future, -there should be an interface to CRUD exams. +Create an exam by pressing `Add Exam` on the home page. Click the star next to an exam to mark it as `Active`, so students can see their seat for that exam. ## Setup (development) 1. Clone the repository and change directories into the repository. @@ -162,7 +156,7 @@ export FLASK_APP = server (or server/__init__.py) export FLASK_ENV = development ``` -6. Modify `config.py` as necessary. Set `OK_CLIENT_ID`, `OK_CLIENT_SECRET`, `GOOGLE_OAUTH2_CLIENT_ID`, `GOOGLE_OAUTH2_CLIENT_SECRET`, `SENDGRID_API_KEY`, `PHOTO_DIRECTORY`, `EXAM`, `ADMIN` as needed. +6. Modify `config.py` as necessary. Set `AUTH_KEY`, `AUTH_CLIENT_SECRET`, `SENDGRID_API_KEY`, `PHOTO_DIRECTORY`, `ADMIN` as needed. 7. Import [demo data](https://docs.google.com/spreadsheets/d/1nC2vinn0k-_TLO0aLmLZtI9juEoSOVEvA3o40HGvXAw/edit?usp=drive_web&ouid=100612133541464602205) for students and rooms (photos TBA). @@ -205,11 +199,8 @@ SECRET_KEY DATABASE_URL OK_CLIENT_ID OK_CLIENT_SECRET -GOOGLE_CLIENT_ID -GOOGLE_CLIENT_SECRET -COURSE -EXAM -DOMAIN +AUTH_KEY +AUTH_CLIENT_SECRET PHOTO_DIRECTORY=/app/storage ADMIN ``` From e5ee6b47222962bc97cc1623066c36653d3f6341 Mon Sep 17 00:00:00 2001 From: rahularya Date: Tue, 18 Feb 2020 04:40:00 -0800 Subject: [PATCH 13/34] added debug --- server/views.py | 1 + 1 file changed, 1 insertion(+) diff --git a/server/views.py b/server/views.py index 9d33c32..9f194bb 100644 --- a/server/views.py +++ b/server/views.py @@ -594,6 +594,7 @@ def new_photos(exam): continue secure_name = secure_filename(name) path = os.path.join(app.config["PHOTO_DIRECTORY"], exam.offering, secure_name) + print(path) os.makedirs(os.path.dirname(path), exist_ok=True) with open(path, "wb+") as g: g.write(zf.open(name, "r").read()) From 613e32cec60f8f232dd9b65a538567149df3d396 Mon Sep 17 00:00:00 2001 From: rahularya Date: Tue, 18 Feb 2020 04:46:46 -0800 Subject: [PATCH 14/34] remove debug --- server/views.py | 1 - 1 file changed, 1 deletion(-) diff --git a/server/views.py b/server/views.py index 9f194bb..9d33c32 100644 --- a/server/views.py +++ b/server/views.py @@ -594,7 +594,6 @@ def new_photos(exam): continue secure_name = secure_filename(name) path = os.path.join(app.config["PHOTO_DIRECTORY"], exam.offering, secure_name) - print(path) os.makedirs(os.path.dirname(path), exist_ok=True) with open(path, "wb+") as g: g.write(zf.open(name, "r").read()) From c98ad73615b29cc96b04667d8bb3984bf7dc2576 Mon Sep 17 00:00:00 2001 From: rahularya Date: Wed, 19 Feb 2020 14:59:48 -0800 Subject: [PATCH 15/34] stuff --- Procfile | 2 +- server/templates/assign.html.j2 | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/Procfile b/Procfile index 2d01c50..8df7900 100644 --- a/Procfile +++ b/Procfile @@ -1 +1 @@ -web: gunicorn -b 0.0.0.0:$PORT server:app +web: gunicorn --workers 8 --timeout 240 -b 0.0.0.0:$PORT server:app diff --git a/server/templates/assign.html.j2 b/server/templates/assign.html.j2 index 422920c..5c1f7e4 100644 --- a/server/templates/assign.html.j2 +++ b/server/templates/assign.html.j2 @@ -5,6 +5,7 @@ {% call macros.form(form) %}
    + This may take a while. {{ form.submit(class="mdl-button mdl-js-button mdl-button--raised") }}
    From 746b2f3bdee36e5646c03920f81c51bba4afa757 Mon Sep 17 00:00:00 2001 From: rahularya Date: Thu, 20 Feb 2020 06:27:05 -0800 Subject: [PATCH 16/34] Hide links in frontend based on admin status --- server/templates/exam.html.j2 | 47 +++++++++++++++----------- server/templates/exam_base.html.j2 | 2 ++ server/templates/offering.j2 | 53 +++++++++++++++++++----------- server/templates/students.html.j2 | 2 ++ server/views.py | 14 ++++++-- 5 files changed, 76 insertions(+), 42 deletions(-) diff --git a/server/templates/exam.html.j2 b/server/templates/exam.html.j2 index 873496f..dfffb97 100644 --- a/server/templates/exam.html.j2 +++ b/server/templates/exam.html.j2 @@ -2,25 +2,32 @@ {% import 'macros.html.j2' as macros with context %} {% block body %} -
    -
    -

    Rooms

    -
      - {% for room in exam.rooms %} -
    • - - {{ room.display_name }} - - {% if current_user.email == config['ADMIN'] %} -
      clear
      +
      +
      +

      Rooms

      + +
      + {% if is_admin %} + {% endif %} -
    • - {% endfor %} -
    -
    - -
    + {% endblock %} diff --git a/server/templates/exam_base.html.j2 b/server/templates/exam_base.html.j2 index 681487c..c851f29 100644 --- a/server/templates/exam_base.html.j2 +++ b/server/templates/exam_base.html.j2 @@ -30,9 +30,11 @@ Rooms expand_more
      + {% if is_admin %} add Add rooms... + {% endif %} {% for room in exam.rooms %}
    • diff --git a/server/templates/offering.j2 b/server/templates/offering.j2 index cf4dfc7..6c9ae3a 100644 --- a/server/templates/offering.j2 +++ b/server/templates/offering.j2 @@ -2,24 +2,37 @@ {% import 'macros.html.j2' as macros with context %} {% block body %} -
      - - -
      +
      +
      +

      Exams

      +
        + {% for exam in exams %} +
      • + {% if is_admin %} +
        {{ "star" if exam.is_active else "star_border" }}
        + {% endif %} + + {{ exam.display_name }} + + {% if is_admin %} +
        + clear +
        + {% endif %} +
      • + {% endfor %} +
      +
      + {% if is_admin %} + + {% endif %} +
      {% endblock %} diff --git a/server/templates/students.html.j2 b/server/templates/students.html.j2 index 478268e..cb2c75e 100644 --- a/server/templates/students.html.j2 +++ b/server/templates/students.html.j2 @@ -16,6 +16,7 @@ + {% if is_admin %}
      email
      Email
      @@ -30,6 +31,7 @@
      person_add
      Add Students
      + {% endif %} diff --git a/server/views.py b/server/views.py index 9d33c32..c477390 100644 --- a/server/views.py +++ b/server/views.py @@ -6,7 +6,7 @@ import requests import sendgrid -from flask import abort, redirect, render_template, request, send_file, session, url_for +from flask import abort, redirect, render_template, request, send_file, session, url_for, g from flask_login import current_user from flask_wtf import FlaskForm from werkzeug.exceptions import HTTPException @@ -42,6 +42,16 @@ def get_endpoint(course=None): return COURSE_ENDPOINTS[course] +def is_admin(course=None): + if not course: + course = get_course() + if g.get("is_admin") is None: + g.is_admin = requests.post("https://auth.apps.cs61a.org/admins/{}/is_admin".format(course), json={ + "email": current_user.email + }).json() + return g.is_admin + + def format_coursecode(course): m = re.match(r"([a-z]+)([0-9]+[a-z]?)", course) return m and (m.group(1) + " " + m.group(2)).upper() @@ -611,7 +621,7 @@ def room(exam, name): def students(exam): # TODO load assignment and seat at the same time? students = Student.query.filter_by(exam_id=exam.id).all() - return render_template('students.html.j2', exam=exam, students=students) + return render_template('students.html.j2', exam=exam, students=students, is_admin=is_admin()) @app.route('//students//') From 7badb48d09c5a688dd894ff6c6f81052cd6a8a26 Mon Sep 17 00:00:00 2001 From: rahularya Date: Thu, 20 Feb 2020 06:30:33 -0800 Subject: [PATCH 17/34] added flag to missing templates --- server/views.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/views.py b/server/views.py index c477390..13e8cf0 100644 --- a/server/views.py +++ b/server/views.py @@ -524,7 +524,7 @@ def offering(offering): if offering not in current_user.offerings: abort(401) exams = Exam.query.filter(Exam.offering==offering) - return render_template("offering.j2", title="{} Exam Seating".format(format_coursecode(get_course())), exams=exams, offering=offering) + return render_template("offering.j2", title="{} Exam Seating".format(format_coursecode(get_course())), exams=exams, offering=offering, is_admin=is_admin()) @app.route('/favicon.ico') @@ -580,7 +580,7 @@ def toggle_exam(exam): @app.route('//') def exam(exam): - return render_template('exam.html.j2', exam=exam) + return render_template('exam.html.j2', exam=exam, is_admin=is_admin()) @app.route('//help/') From 44124d4c289f5ae90537f94707096564025243d3 Mon Sep 17 00:00:00 2001 From: rahularya Date: Thu, 20 Feb 2020 06:30:33 -0800 Subject: [PATCH 18/34] added flag to missing templates --- server/templates/new_students.html.j2 | 2 +- server/views.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/server/templates/new_students.html.j2 b/server/templates/new_students.html.j2 index 64bb608..fd9d24c 100644 --- a/server/templates/new_students.html.j2 +++ b/server/templates/new_students.html.j2 @@ -40,7 +40,7 @@ max-height:100%; padding-bottom: 50px;">

      To import students, create a Google spreadsheet with the columns "Name", - "Student ID", "Email", and "bCourses ID". The remaining columns are arbitrary attributes + "Student ID", "Email", and "bCourses ID". Make sure that the service account secure-links@ok-server.iam.gserviceaccount.com has access to that spreadsheet somehow. The remaining columns are arbitrary attributes (ex: LEFTY, RIGHTY, BROKEN) that express student preferences.

      For example, if a student has LEFTY=TRUE, they will be assigned a seat with the diff --git a/server/views.py b/server/views.py index c477390..13e8cf0 100644 --- a/server/views.py +++ b/server/views.py @@ -524,7 +524,7 @@ def offering(offering): if offering not in current_user.offerings: abort(401) exams = Exam.query.filter(Exam.offering==offering) - return render_template("offering.j2", title="{} Exam Seating".format(format_coursecode(get_course())), exams=exams, offering=offering) + return render_template("offering.j2", title="{} Exam Seating".format(format_coursecode(get_course())), exams=exams, offering=offering, is_admin=is_admin()) @app.route('/favicon.ico') @@ -580,7 +580,7 @@ def toggle_exam(exam): @app.route('//') def exam(exam): - return render_template('exam.html.j2', exam=exam) + return render_template('exam.html.j2', exam=exam, is_admin=is_admin()) @app.route('//help/') From e02bddde0b5722bdac965274a8b00c923947c3b3 Mon Sep 17 00:00:00 2001 From: rahularya Date: Thu, 27 Feb 2020 13:42:33 -0800 Subject: [PATCH 19/34] updated is_admin --- server/views.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/server/views.py b/server/views.py index 13e8cf0..fdf2eff 100644 --- a/server/views.py +++ b/server/views.py @@ -47,7 +47,9 @@ def is_admin(course=None): course = get_course() if g.get("is_admin") is None: g.is_admin = requests.post("https://auth.apps.cs61a.org/admins/{}/is_admin".format(course), json={ - "email": current_user.email + "email": current_user.email, + "client_name": app.config["AUTH_KEY"], + "secret": app.config["AUTH_CLIENT_SECRET"], }).json() return g.is_admin From 9dd542848b85b5cbf4b512fae5312842798cba97 Mon Sep 17 00:00:00 2001 From: Nancy Shaw <33583144+itsnshaw@users.noreply.github.com> Date: Fri, 28 Feb 2020 07:34:13 -0800 Subject: [PATCH 20/34] Update README.md --- README.md | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 47b61bb..22b76d5 100644 --- a/README.md +++ b/README.md @@ -168,6 +168,8 @@ This commands only needs to be run once. 6. Open [localhost:5000](https://localhost:5000) +## Production (Adding Another Class) + Update configs at https://auth.apps.cs61a.org/ where relevant ## Production (First Time Deployment on dokku) dokku apps:create seating @@ -185,12 +187,7 @@ This commands only needs to be run once. dokku run seating flask initdb dokku letsencrypt seating -In addition, add the following to `/home/dokku/seating/nginx.conf`: -``` -proxy_buffer_size 128k; -proxy_buffers 4 256k; -proxy_busy_buffers_size 256k; -``` +Also update any necessary configurations on GCLOUD. (There will be a linked URL). ## Environment variables ``` From 3c8da46cbe0824143933096ecad03ffa1c8881b4 Mon Sep 17 00:00:00 2001 From: Nancy Date: Fri, 28 Feb 2020 12:05:10 -0800 Subject: [PATCH 21/34] Reassign seat assignments --- server/templates/reassign_seat.html.j2 | 39 ++++++++++++ server/templates/student.html.j2 | 3 + server/views.py | 83 +++++++++++++++++--------- 3 files changed, 98 insertions(+), 27 deletions(-) create mode 100644 server/templates/reassign_seat.html.j2 diff --git a/server/templates/reassign_seat.html.j2 b/server/templates/reassign_seat.html.j2 new file mode 100644 index 0000000..24cfc33 --- /dev/null +++ b/server/templates/reassign_seat.html.j2 @@ -0,0 +1,39 @@ +{% extends 'exam_base.html.j2' %} +{% import 'macros.html.j2' as macros with context %} + +{% block body %} +{% call macros.form(form) %} +

      +
      +

      Reassign Seat for {{ student.name }}

      + + {% set seat = student.assignment.seat %} + Current Seat: + {{ seat.room.display_name }} {{ seat.name }} + + +
      +
      + {{ form.new_room(class="mdl-textfield__input", type="New Room", id="gsurl") }} + +
      +
      + {{ form.new_seat(class="mdl-textfield__input", type="New Seat (Example: A13)", id="gsurl") }} + +
      +
      + {{ form.submit(class="mdl-button mdl-js-button mdl-button--raised") }} +
      +
      +
      + {% if form.new_seat.errors %} + {% for error in form.new_seat.errors %} + {{ form.new_seat.errors }} + {% endfor %} + {% endif %} +
      +
      +
      + +{% endcall %} +{% endblock %} diff --git a/server/templates/student.html.j2 b/server/templates/student.html.j2 index 66be4bc..58b27f9 100644 --- a/server/templates/student.html.j2 +++ b/server/templates/student.html.j2 @@ -16,6 +16,9 @@ {{ seat.room.display_name }} {{ seat.name }}

      + + Edit Seat Assignment +

      Sharable Link to This Seat diff --git a/server/views.py b/server/views.py index 9d33c32..759f66a 100644 --- a/server/views.py +++ b/server/views.py @@ -12,8 +12,8 @@ from werkzeug.exceptions import HTTPException from werkzeug.routing import BaseConverter from werkzeug.utils import secure_filename -from wtforms import SelectMultipleField, StringField, SubmitField, TextAreaField, widgets, FileField -from wtforms.validators import Email, InputRequired, URL +from wtforms import SelectMultipleField, SelectField, StringField, SubmitField, TextAreaField, widgets, FileField +from wtforms.validators import Email, InputRequired, URL, ValidationError from server import app from server.models import Exam, Room, Seat, SeatAssignment, Student, db, slug @@ -23,6 +23,30 @@ DOMAIN_COURSES = {} COURSE_ENDPOINTS = {} +rooms = [('277 Cory', '277 Cory'), + ('145 Dwinelle', '145 Dwinelle'), + ('155 Dwinelle', '155 Dwinelle'), + ('10 Evans', '10 Evans'), + ('100 GPB', '100 GPB'), + ('A1 Hearst Field Annex', 'A1 Hearst Field Annex'), + ('Hearst Gym', 'Hearst Gym'), + ('120 Latimer', '120 Latimer'), + ('1 LeConte', '1 LeConte'), + ('2 LeConte', '2 LeConte'), + ('4 LeConte', '4 LeConte'), + ('100 Lewis', '100 Lewis'), + ('245 Li Ka Shing', '245 Li Ka Shing'), + ('159 Mulford', '159 Mulford'), + ('105 North Gate', '105 North Gate'), + ('1 Pimentel', '1 Pimentel'), + ('RSF FH
', 'RSF FH
'), + ('306 Soda', '306 Soda'), + ('2040 VLSB', '2040 VLSB'), + ('2050 VLSB', '2050 VLSB'), + ('2060 VLSB', '2060 VLSB'), + ('150 Wheeler', '150 Wheeler'), + ('222 Wheeler', '222 Wheeler') + ] def get_course(domain=None): if not domain: @@ -109,30 +133,7 @@ class RoomForm(FlaskForm): class MultRoomForm(FlaskForm): - rooms = MultiCheckboxField(choices=[('277 Cory', '277 Cory'), - ('145 Dwinelle', '145 Dwinelle'), - ('155 Dwinelle', '155 Dwinelle'), - ('10 Evans', '10 Evans'), - ('100 GPB', '100 GPB'), - ('A1 Hearst Field Annex', 'A1 Hearst Field Annex'), - ('Hearst Gym', 'Hearst Gym'), - ('120 Latimer', '120 Latimer'), - ('1 LeConte', '1 LeConte'), - ('2 LeConte', '2 LeConte'), - ('4 LeConte', '4 LeConte'), - ('100 Lewis', '100 Lewis'), - ('245 Li Ka Shing', '245 Li Ka Shing'), - ('159 Mulford', '159 Mulford'), - ('105 North Gate', '105 North Gate'), - ('1 Pimentel', '1 Pimentel'), - ('RSF FH
', 'RSF FH
'), - ('306 Soda', '306 Soda'), - ('2040 VLSB', '2040 VLSB'), - ('2050 VLSB', '2050 VLSB'), - ('2060 VLSB', '2060 VLSB'), - ('150 Wheeler', '150 Wheeler'), - ('222 Wheeler', '222 Wheeler') - ]) + rooms = MultiCheckboxField(choices=rooms) submit = SubmitField('import') @@ -577,7 +578,6 @@ def exam(exam): def help(exam): return render_template('help.html.j2', exam=exam) - class PhotosForm(FlaskForm): file = FileField("file", [InputRequired()]) submit = SubmitField('Submit') @@ -599,6 +599,35 @@ def new_photos(exam): g.write(zf.open(name, "r").read()) return render_template('new_photos.html.j2', exam=exam, form=form) +class SeatForm(FlaskForm): + new_room = SelectField('New Room') + new_seat = StringField('New Seat', [InputRequired()]) + submit = SubmitField('Submit') + exam = None + +@app.route('//students//reassign_seat', methods=['GET', 'POST']) +def reassign_seat(exam, email): + form = SeatForm() + form.exam = exam + available_rooms = Room.query.filter_by(exam_id=exam.id).all() + form.new_room.choices = [(r.name, r.display_name) for r in available_rooms] + student = Student.query.filter_by(exam_id=exam.id, email=email).first_or_404() + if form.validate_on_submit(): + try: + room = Room.query.filter_by(exam_id=exam.id, name=form.new_room.data).first() + seat = Seat.query.filter_by(room_id=room.id, name=form.new_seat.data).first() + if not seat: + raise ValidationError('Seat does not exist') + if SeatAssignment.query.filter_by(seat_id=seat.id).first(): + raise ValidationError('Seat is not empty. Please reassign the currently assigned student first') + except ValidationError as e: + form.new_seat.errors.append(str(e)) + else: + db.session.query(SeatAssignment).filter_by(student_id=student.id).update({'seat_id':seat.id}) + db.session.commit() + return redirect(url_for('room', exam=exam, name=room.name)) + return render_template('reassign_seat.html.j2', exam=exam, student=student, form=form) + @app.route('//rooms//') def room(exam, name): From 7f122e3acf2088c69c43bcbd5c22fd1072aa371e Mon Sep 17 00:00:00 2001 From: Nancy Date: Fri, 28 Feb 2020 12:06:34 -0800 Subject: [PATCH 22/34] bug fix --- server/views.py | 1 - 1 file changed, 1 deletion(-) diff --git a/server/views.py b/server/views.py index 759f66a..951d47b 100644 --- a/server/views.py +++ b/server/views.py @@ -603,7 +603,6 @@ class SeatForm(FlaskForm): new_room = SelectField('New Room') new_seat = StringField('New Seat', [InputRequired()]) submit = SubmitField('Submit') - exam = None @app.route('//students//reassign_seat', methods=['GET', 'POST']) def reassign_seat(exam, email): From db9913230626063016e0d4d49444da6bf7e0f7d7 Mon Sep 17 00:00:00 2001 From: Nancy Date: Fri, 28 Feb 2020 12:10:53 -0800 Subject: [PATCH 23/34] check if assigned seat --- server/templates/reassign_seat.html.j2 | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/server/templates/reassign_seat.html.j2 b/server/templates/reassign_seat.html.j2 index 24cfc33..96fd54e 100644 --- a/server/templates/reassign_seat.html.j2 +++ b/server/templates/reassign_seat.html.j2 @@ -7,11 +7,15 @@

      Reassign Seat for {{ student.name }}

      - {% set seat = student.assignment.seat %} - Current Seat: - {{ seat.room.display_name }} {{ seat.name }} - - + {% if student.assignment %} + {% set seat = student.assignment.seat %} + Current Seat: + {{ seat.room.display_name }} {{ seat.name }} + + {% else %} + No Assigned Seat + {% endif %} +
      {{ form.new_room(class="mdl-textfield__input", type="New Room", id="gsurl") }} From 6bacaa6efcbce39710eef432309671c1c9e33e72 Mon Sep 17 00:00:00 2001 From: Nancy Date: Fri, 28 Feb 2020 12:18:02 -0800 Subject: [PATCH 24/34] Add clearer error msg for auth --- server/views.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/server/views.py b/server/views.py index 951d47b..27add89 100644 --- a/server/views.py +++ b/server/views.py @@ -138,12 +138,15 @@ class MultRoomForm(FlaskForm): def read_csv(sheet_url, sheet_range): - values = requests.post("https://auth.apps.cs61a.org/google/read_spreadsheet", json={ - "url": sheet_url, - "sheet_name": sheet_range, - "client_name": app.config["AUTH_KEY"], - "secret": app.config["AUTH_CLIENT_SECRET"], - }).json() + try: + values = requests.post("https://auth.apps.cs61a.org/google/read_spreadsheet", json={ + "url": sheet_url, + "sheet_name": sheet_range, + "client_name": app.config["AUTH_KEY"], + "secret": app.config["AUTH_CLIENT_SECRET"], + }).json() + except: + raise ValidationError('Could not reach Google Sheet. Please make sure your sheet is shared with cs61a@berkeley.edu.') if not values: raise ValidationError('Sheet is empty') From 3261df0bda97c26d4ca2bc27bc49709deeeb292b Mon Sep 17 00:00:00 2001 From: Nancy Date: Fri, 28 Feb 2020 12:39:18 -0800 Subject: [PATCH 25/34] Add student count per room --- server/templates/room.html.j2 | 1 + server/views.py | 6 +++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/server/templates/room.html.j2 b/server/templates/room.html.j2 index 4037333..ed44935 100644 --- a/server/templates/room.html.j2 +++ b/server/templates/room.html.j2 @@ -5,4 +5,5 @@ {% block body %} {{ macros.room(room, highlight_seat=seat) }} +

      Total Students: {{ total }}

      {% endblock %} diff --git a/server/views.py b/server/views.py index 27add89..2313c8a 100644 --- a/server/views.py +++ b/server/views.py @@ -9,6 +9,7 @@ from flask import abort, redirect, render_template, request, send_file, session, url_for from flask_login import current_user from flask_wtf import FlaskForm +from sqlalchemy import func from werkzeug.exceptions import HTTPException from werkzeug.routing import BaseConverter from werkzeug.utils import secure_filename @@ -635,7 +636,10 @@ def reassign_seat(exam, email): def room(exam, name): room = Room.query.filter_by(exam_id=exam.id, name=name).first_or_404() seat = request.args.get('seat') - return render_template('room.html.j2', exam=exam, room=room, seat=seat) + max_seatid = db.session.query(func.max(Seat.id)).filter_by(room_id=room.id) + min_seatid = db.session.query(func.min(Seat.id)).filter_by(room_id=room.id) + total = db.session.query(SeatAssignment).filter(SeatAssignment.seat_id<= max_seatid, SeatAssignment.seat_id>= min_seatid).count() + return render_template('room.html.j2', exam=exam, room=room, seat=seat, total=total) @app.route('//students/') From d4c0ee515efb2699dc1715564a483b6dfa05131c Mon Sep 17 00:00:00 2001 From: Nancy Shaw <33583144+itsnshaw@users.noreply.github.com> Date: Fri, 28 Feb 2020 12:58:06 -0800 Subject: [PATCH 26/34] Update README.md --- README.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/README.md b/README.md index 22b76d5..1a44d1e 100644 --- a/README.md +++ b/README.md @@ -12,9 +12,6 @@ and students with the appropriate roles. ## Usage (Admin TAs for Courses) -It's janky. The only way to -correct errors is to manually edit the database, so be careful. - In summary, setting up the seating chart involves these steps: 0. **Register your course** on [auth.apps.cs61a.org], specifying your OKPy endpoint, and adding your desired domain for the seating app. Then contact us to activate the seating app for that domain. 1. **Create an exam** (ex. Midterm 1 or Final). From d48720bdace17e7a4a9aed4ce4bb7579309b997c Mon Sep 17 00:00:00 2001 From: Nancy Shaw <33583144+itsnshaw@users.noreply.github.com> Date: Fri, 28 Feb 2020 13:03:04 -0800 Subject: [PATCH 27/34] Fix validation URL --- server/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/views.py b/server/views.py index 2313c8a..45d005e 100644 --- a/server/views.py +++ b/server/views.py @@ -147,7 +147,7 @@ def read_csv(sheet_url, sheet_range): "secret": app.config["AUTH_CLIENT_SECRET"], }).json() except: - raise ValidationError('Could not reach Google Sheet. Please make sure your sheet is shared with cs61a@berkeley.edu.') + raise ValidationError('Could not reach Google Sheet. Please make sure your sheet is shared with secure-links@ok-server.iam.gserviceaccount.com') if not values: raise ValidationError('Sheet is empty') From 27577a47ae103e9cb0daca7923eaf54f9a5c2cb6 Mon Sep 17 00:00:00 2001 From: rahularya Date: Fri, 28 Feb 2020 22:11:48 -0800 Subject: [PATCH 28/34] tweaked text --- server/templates/new_photos.html.j2 | 2 +- server/views.py | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/server/templates/new_photos.html.j2 b/server/templates/new_photos.html.j2 index 668f820..c2ed383 100644 --- a/server/templates/new_photos.html.j2 +++ b/server/templates/new_photos.html.j2 @@ -7,7 +7,7 @@

      Photos Upload

      First, download this script - and follow the instructions. Zip up the photos in the format of bcoursesID.jpeg (make sure it's not .jpg!). + and follow the instructions. Zip up the photos in the format of bcoursesID.jpg or bcoursesID.jpeg. Then upload that zip here. Photos may be cleared at the end of the semester, or as space constraints dictate.

      diff --git a/server/views.py b/server/views.py index fdf2eff..7911997 100644 --- a/server/views.py +++ b/server/views.py @@ -635,8 +635,11 @@ def student(exam, email): @app.route('//students//photo') def photo(exam, email): student = Student.query.filter_by(exam_id=exam.id, email=email).first_or_404() - photo_path = os.path.join(app.config['PHOTO_DIRECTORY'], exam.offering, student.bcourses_id) + ".jpeg" - return send_file(photo_path, mimetype='image/jpeg') + photo_path = os.path.join(app.config['PHOTO_DIRECTORY'], exam.offering, student.bcourses_id) + if os.path.exists(photo_path + ".jpeg"): + return send_file(photo_path + ".jpeg", mimetype='image/jpeg') + else: + return send_file(photo_path + ".jpg", mimetype='image/jpeg') @app.route('/seat//') From 3a8e97dbf5124e68dd8715c3314dc2d180904dc7 Mon Sep 17 00:00:00 2001 From: rahularya Date: Sun, 1 Mar 2020 18:36:55 -0800 Subject: [PATCH 29/34] add debug --- server/views.py | 1 + 1 file changed, 1 insertion(+) diff --git a/server/views.py b/server/views.py index 6a77e2d..4a30601 100644 --- a/server/views.py +++ b/server/views.py @@ -613,6 +613,7 @@ def new_photos(exam): os.makedirs(os.path.dirname(path), exist_ok=True) with open(path, "wb+") as g: g.write(zf.open(name, "r").read()) + print(path, name) return render_template('new_photos.html.j2', exam=exam, form=form) class SeatForm(FlaskForm): From 0c66b6a1651aea1e318a0f01195079116215b35b Mon Sep 17 00:00:00 2001 From: rahularya Date: Sun, 1 Mar 2020 18:53:08 -0800 Subject: [PATCH 30/34] fix image bug --- server/views.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/server/views.py b/server/views.py index 4a30601..ce87999 100644 --- a/server/views.py +++ b/server/views.py @@ -606,14 +606,19 @@ def new_photos(exam): f = form.file.data zf = zipfile.ZipFile(f, mode="r") for name in zf.namelist(): + print(name) if name.endswith("/"): continue - secure_name = secure_filename(name) - path = os.path.join(app.config["PHOTO_DIRECTORY"], exam.offering, secure_name) + if name.count("/") > 1: + continue + match = re.search(f"([0-9]+)\.jpe?g", name) + if not match: + continue + sid = match.group(1) + path = os.path.join(app.config["PHOTO_DIRECTORY"], exam.offering, sid + ".jpg") os.makedirs(os.path.dirname(path), exist_ok=True) with open(path, "wb+") as g: g.write(zf.open(name, "r").read()) - print(path, name) return render_template('new_photos.html.j2', exam=exam, form=form) class SeatForm(FlaskForm): From 378ab50ff389a38841bf92e2f6051e264da66b62 Mon Sep 17 00:00:00 2001 From: rahularya Date: Sun, 1 Mar 2020 18:54:44 -0800 Subject: [PATCH 31/34] typo --- server/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/views.py b/server/views.py index ce87999..3dda74a 100644 --- a/server/views.py +++ b/server/views.py @@ -611,7 +611,7 @@ def new_photos(exam): continue if name.count("/") > 1: continue - match = re.search(f"([0-9]+)\.jpe?g", name) + match = re.search(r"([0-9]+)\.jpe?g", name) if not match: continue sid = match.group(1) From 83cbdc2aa8796ecbea387538febcbaa06b6fed9e Mon Sep 17 00:00:00 2001 From: rahularya Date: Sun, 1 Mar 2020 19:00:47 -0800 Subject: [PATCH 32/34] bump From 8d0225030473fe6d581fbb241518dca73ecf12d0 Mon Sep 17 00:00:00 2001 From: rahularya Date: Tue, 21 Apr 2020 15:46:33 +0800 Subject: [PATCH 33/34] feat: switch to a super-client --- server/views.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/server/views.py b/server/views.py index 3dda74a..fdb4136 100644 --- a/server/views.py +++ b/server/views.py @@ -71,10 +71,11 @@ def is_admin(course=None): if not course: course = get_course() if g.get("is_admin") is None: - g.is_admin = requests.post("https://auth.apps.cs61a.org/admins/{}/is_admin".format(course), json={ + g.is_admin = requests.post("https://auth.apps.cs61a.org/admins/is_admin", json={ "email": current_user.email, "client_name": app.config["AUTH_KEY"], "secret": app.config["AUTH_CLIENT_SECRET"], + "course": course, }).json() return g.is_admin @@ -155,6 +156,7 @@ def read_csv(sheet_url, sheet_range): values = requests.post("https://auth.apps.cs61a.org/google/read_spreadsheet", json={ "url": sheet_url, "sheet_name": sheet_range, + "course": "cs61a", "client_name": app.config["AUTH_KEY"], "secret": app.config["AUTH_CLIENT_SECRET"], }).json() From 6d3685222be797cd8fc29f19779b95eba4cf70ca Mon Sep 17 00:00:00 2001 From: Mehrdad <> Date: Sat, 19 Feb 2022 21:19:33 -0600 Subject: [PATCH 34/34] Avoid encoding= parameter in json.load() as it has been deprecated --- download_bcourses_photos.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/download_bcourses_photos.py b/download_bcourses_photos.py index fdff9f1..6e9deaa 100644 --- a/download_bcourses_photos.py +++ b/download_bcourses_photos.py @@ -39,7 +39,7 @@ def get_content_charset(headers): def read_http_response_as_json(response): - return json.load(response, encoding=get_content_charset(response.headers)) + return json.loads(response.read().decode(get_content_charset(response.headers))) def fetcher(q):