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/README.md b/README.md index 57bc311..1a44d1e 100644 --- a/README.md +++ b/README.md @@ -12,13 +12,9 @@ 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 -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 +53,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 +113,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 +121,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 +153,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). @@ -174,6 +165,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 @@ -191,12 +184,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 ``` @@ -205,11 +193,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 ``` diff --git a/config.py b/config.py index 2e3cbbc..f58e666 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,21 +16,17 @@ # 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') +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') -DOMAIN = os.getenv('DOMAIN', 'https://seating.test.org') -PHOTO_DIRECTORY = os.getenv('PHOTO_DIRECTORY') - -# Used for redirects and auth: /COURSE/EXAM -COURSE = os.getenv('COURSE', 'cal/test/fa18') -EXAM = os.getenv('EXAM') +# 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/download_bcourses_photos.py b/download_bcourses_photos.py index 5036992..6e9deaa 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.loads(response.read().decode(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..aac1a25 100644 --- a/server/auth.py +++ b/server/auth.py @@ -1,11 +1,13 @@ 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, Exam +from server.views import get_endpoint + +AUTHORIZED_ROLES = ["instructor", "staff", "grader"] login_manager = LoginManager(app=app) @@ -30,8 +32,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) @@ -58,12 +58,13 @@ 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'], - 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: @@ -71,18 +72,16 @@ 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: 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) - 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() @@ -91,6 +90,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 bc4c95b..9471f42 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 @@ -36,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' @@ -120,13 +120,14 @@ 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() + @app.cli.command('resetdb') @click.pass_context def reset_db(ctx): @@ -135,20 +136,29 @@ 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="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/assign.html.j2 b/server/templates/assign.html.j2 index 0b2e46a..5c1f7e4 100644 --- a/server/templates/assign.html.j2 +++ b/server/templates/assign.html.j2 @@ -1,10 +1,11 @@ -{% extends 'base.html.j2' %} +{% extends 'exam_base.html.j2' %} {% import 'macros.html.j2' as macros with context %} {% block body %} {% call macros.form(form) %}
+ This may take a while. {{ form.submit(class="mdl-button mdl-js-button mdl-button--raised") }}
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..dfffb97 100644 --- a/server/templates/exam.html.j2 +++ b/server/templates/exam.html.j2 @@ -1,26 +1,33 @@ -{% extends 'base.html.j2' %} +{% extends 'exam_base.html.j2' %} {% 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 new file mode 100644 index 0000000..c851f29 --- /dev/null +++ b/server/templates/exam_base.html.j2 @@ -0,0 +1,60 @@ + + + + + + + + + + + + + {% block title %}{{ exam.display_name }}{% endblock %} + + + + +
    +
    +
    + + + {{ exam.display_name }} + + +
    + +
    +
    + +
    + {% 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/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_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/new_photos.html.j2 b/server/templates/new_photos.html.j2 index 2870242..c2ed383 100644 --- a/server/templates/new_photos.html.j2 +++ b/server/templates/new_photos.html.j2 @@ -1,10 +1,54 @@ -{% extends 'base.html.j2' %} +{% 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.jpg or bcoursesID.jpeg. + 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/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..fd9d24c 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 %} @@ -40,8 +40,8 @@ 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 - (ex: LEFTY, RIGHTY, BROKEN) that express student preferences.

    + "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 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/offering.j2 b/server/templates/offering.j2 new file mode 100644 index 0000000..6c9ae3a --- /dev/null +++ b/server/templates/offering.j2 @@ -0,0 +1,38 @@ +{% extends 'base.html.j2' %} +{% 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/reassign_seat.html.j2 b/server/templates/reassign_seat.html.j2 new file mode 100644 index 0000000..96fd54e --- /dev/null +++ b/server/templates/reassign_seat.html.j2 @@ -0,0 +1,43 @@ +{% extends 'exam_base.html.j2' %} +{% import 'macros.html.j2' as macros with context %} + +{% block body %} +{% call macros.form(form) %} +
+
+

Reassign Seat for {{ student.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") }} + +
+
+ {{ 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/room.html.j2 b/server/templates/room.html.j2 index 147d3b9..ed44935 100644 --- a/server/templates/room.html.j2 +++ b/server/templates/room.html.j2 @@ -1,8 +1,9 @@ -{% extends 'base.html.j2' %} +{% extends 'exam_base.html.j2' %} {% import 'macros.html.j2' as macros with context %} {% block title %}{{ room.display_name }} | {{ super() }}{% endblock %} {% block body %} {{ macros.room(room, highlight_seat=seat) }} +

Total Students: {{ total }}

{% endblock %} diff --git a/server/templates/student.html.j2 b/server/templates/student.html.j2 index abd6708..58b27f9 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 %} @@ -16,6 +16,9 @@ {{ seat.room.display_name }} {{ seat.name }}

+ + Edit Seat Assignment +

Sharable Link to This Seat diff --git a/server/templates/students.html.j2 b/server/templates/students.html.j2 index a58427b..cb2c75e 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 %} @@ -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 79dd2d7..fdb4136 100644 --- a/server/views.py +++ b/server/views.py @@ -2,57 +2,142 @@ import os import random import re -import sys +import zipfile -from apiclient import discovery, errors -from flask import abort, redirect, render_template, request, send_file, session, url_for +import requests +import sendgrid +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 -import sendgrid +from sqlalchemy import func from werkzeug.exceptions import HTTPException from werkzeug.routing import BaseConverter -from wtforms import HiddenField, SelectMultipleField, StringField, SubmitField, TextAreaField, widgets -from wtforms.validators import Email, InputRequired, URL +from werkzeug.utils import secure_filename +from wtforms import SelectMultipleField, SelectField, StringField, SubmitField, TextAreaField, widgets, FileField +from wtforms.validators import Email, InputRequired, URL, ValidationError 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 = '[^/]+' +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: + 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("https://auth.apps.cs61a.org/api/{}/get_endpoint".format(course)).json() + 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", json={ + "email": current_user.email, + "client_name": app.config["AUTH_KEY"], + "secret": app.config["AUTH_CLIENT_SECRET"], + "course": course, + }).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() + + 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 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 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): 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,50 +145,23 @@ 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'), - ('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') -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) +def read_csv(sheet_url, sheet_range): 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, + "course": "cs61a", + "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 secure-links@ok-server.iam.gserviceaccount.com') if not values: raise ValidationError('Sheet is empty') @@ -118,6 +176,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 +218,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 +226,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 +250,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 +278,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,17 +294,19 @@ 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: - 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') @@ -253,13 +318,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 +337,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 +361,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 +378,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 +397,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 +423,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 +436,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 +445,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']) @@ -424,7 +497,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) }, ], } @@ -440,6 +513,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,53 +522,168 @@ def email(exam): return redirect(url_for('students', exam=exam)) return render_template('email.html.j2', exam=exam, form=form) -@app.route('/') + +@app.route("/") def index(): - return redirect('/' + app.config['COURSE'] + '/' + app.config['EXAM']) + 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("offering.j2", title="{} Exam Seating".format(format_coursecode(get_course())), exams=exams, offering=offering, is_admin=is_admin()) + @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') +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(offering): + form = ExamForm() + if form.validate_on_submit(): + 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() + + 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"]) +def delete_exam(exam): + db.session.delete(exam) + db.session.commit() + + 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) + return render_template('exam.html.j2', exam=exam, is_admin=is_admin()) + @app.route('//help/') 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(): + print(name) + if name.endswith("/"): + continue + if name.count("/") > 1: + continue + match = re.search(r"([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()) + 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') + +@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): 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/') 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//') 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) - 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//') def single_seat(seat_id):