Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ DB_NAME=
DATABASE_URL=
# 3. Neither set, in development: server/app.db (SQLite).

# Apply database migrations when the server starts. Set on staging and production
# only, never on PR previews (they share the staging database). Locally, run
# `flask --app main db upgrade` yourself instead.
RUN_MIGRATIONS=

# Canvas developer key for this app. Locally, use the UC Berkeley sandbox key:
# https://ucberkeleysandbox.instructure.com/
CANVAS_SERVER_URL=https://ucberkeleysandbox.instructure.com/
Expand Down
7 changes: 4 additions & 3 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,8 @@ EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD python -c 'import os, urllib.request; urllib.request.urlopen("http://127.0.0.1:" + os.environ.get("PORT", "8080") + "/health")'

# With RUN_MIGRATIONS=true (set on staging and production, never on PR previews,
# which share the staging database), apply database migrations before starting.
# `exec` keeps gunicorn as PID 1 so it gets SIGTERM directly on redeploy.
# One worker process with threads, as in seating: Cloud Run's default instance
# is 1 vCPU, and a single worker also keeps db.create_all() at startup from racing.
CMD ["sh", "-c", "exec gunicorn -b 0.0.0.0:${PORT:-8080} main:app --workers ${WEB_CONCURRENCY:-1} --threads ${GUNICORN_THREADS:-8} --timeout ${GUNICORN_TIMEOUT:-120} --access-logfile -"]
# One worker process with threads, as in seating: Cloud Run's default instance is 1 vCPU.
CMD ["sh", "-c", "if [ \"$RUN_MIGRATIONS\" = true ]; then flask --app main db upgrade || exit 1; fi; exec gunicorn -b 0.0.0.0:${PORT:-8080} main:app --workers ${WEB_CONCURRENCY:-1} --threads ${GUNICORN_THREADS:-8} --timeout ${GUNICORN_TIMEOUT:-120} --access-logfile -"]
16 changes: 14 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@ Requires Python 3.14 and Node 24 (`mise install` picks both up from `mise.toml`)
pip install -r server/requirements.txt
```
3. Install the frontend dependencies with `yarn install`.
4. Run both with `yarn dev`, or separately: the backend with `python3 main.py` from `server/` (port 8000) and the frontend with `yarn start` (the Vite dev server on port 3000, which forwards API calls, the login routes and `/offerings` to port 8000).
5. Run the backend tests from the repository root with `python -m unittest discover -s server -p "test_*.py"`.
4. Create or update the local database with `flask --app main db upgrade` from `server/`.
5. Run both with `yarn dev`, or separately: the backend with `python3 main.py` from `server/` (port 8000) and the frontend with `yarn start` (the Vite dev server on port 3000, which forwards API calls, the login routes and `/offerings` to port 8000).
6. Run the backend tests from the repository root with `python -m unittest discover -s server -p "test_*.py"`.

## Authentication

Expand Down Expand Up @@ -50,6 +51,17 @@ python3 import_locally.py --course 157 --type enrollment --file test_csvs/lab_en

4. Open `localhost:3000/offerings/157/`; you should see the example sections.

## Database Migrations

The schema is managed with Flask-Migrate (Alembic); migrations live in `server/migrations/versions`. To change the schema, edit `server/models.py`, then from `server/`:

```
flask --app main db migrate -m "describe the change" # generates a migration; review and edit it
flask --app main db upgrade # applies it to your local database
```

Commit the migration with the model change. Deployments with `RUN_MIGRATIONS=true` (staging and production) run `flask db upgrade` when they start; PR previews never do, because they share the staging database.

## Import Sections from GCP
1. Download the sections database to obtain a `.sql` file. Relevant Links [1](https://cloud.google.com/sql/docs/mysql/import-export/import-export-sql) and [2](https://cloud.google.com/storage/docs/downloading-objects).
2. Likely the export uses MySQL syntax, so update it to SQLite.
Expand Down
37 changes: 12 additions & 25 deletions server/import_sheet.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from offering import get_course
from google_sheets import read_spreadsheet
from dataclasses import asdict, dataclass, field, fields
from models import Failure, Section, User, db, user_section
from models import Failure, Section, db, find_or_add_person, offering_member, user_section

# Sample Spreadsheet Link: https://docs.google.com/spreadsheets/d/1WL7gXiBxPe6aUFBKfEOVS6iY27ITy9mARupLoJX-ouE/edit?usp=sharing
pst = ZoneInfo("US/Pacific")
Expand Down Expand Up @@ -95,16 +95,10 @@ def import_sections(data: Iterator):
raise Failure(f"Unknown boolean value: {can_self_enroll}")
can_self_enroll = can_self_enroll == "true"

staff = User.query.filter_by(
email=email,
course=get_course(),
).one_or_none() or User(
email=email,
name=name,
is_staff=True,
is_admin=False,
course=get_course(),
)
staff = offering_member(email=email)
if staff is None:
staff = find_or_add_person(email, name)
staff.set_role(get_course(), is_staff=True)

section_type = row[header.type_index]
location = row[header.location_index]
Expand Down Expand Up @@ -156,21 +150,14 @@ def import_enrollment(data: Iterator):
start_time = parse_time_string(row[header.day_index], row[header.start_index])
section_type = row[header.type_index]

student = User.query.filter_by(
email=student_email,
course=get_course()
).one_or_none() or User(
email=student_email,
name=student_name,
is_staff=False,
is_admin=False,
course=get_course()
)
student = offering_member(email=student_email)
if student is None:
student = find_or_add_person(student_email, student_name)
student.set_role(get_course(), is_staff=False)

staff = User.query.filter_by(
email=staff_email,
course=get_course(),
).one()
staff = offering_member(email=staff_email)
if staff is None:
raise Failure(f"Unable to import enrollment data for {student_email}! {staff_email} isn't on staff.")

# Assumes that each section can be uniquely identified by the below parameters
section = Section.query.filter_by(
Expand Down
75 changes: 36 additions & 39 deletions server/login.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@
from markupsafe import escape

import canvas_service
from offering import current_offering, get_course, in_offering
from models import Account, User, db
from offering import current_offering, in_offering
from models import User, db

AFTER_LOGIN_KEY = "after_login"

Expand All @@ -22,59 +22,59 @@ def _safe_next(target):


def complete_login(token: dict):
"""Create or update the signed-in Account from a Canvas OAuth token response."""
canvas_user_id = token["user"]["id"]
"""Create or update the signed-in User from a Canvas OAuth token response."""
canvas_user_id = str(token["user"]["id"])
access_token = token["access_token"]
profile = canvas_service.get_profile(canvas_user_id, access_token)
email = profile.get("primary_email") or profile.get("login_id")
name = profile.get("short_name") or profile.get("name") or email
courses = canvas_service.get_user_courses(canvas_user_id, access_token)
override_id = current_app.config.get("ADMIN_OVERRIDE_CANVAS_COURSE_ID")

account = Account.query.filter_by(canvas_id=str(canvas_user_id)).one_or_none()
if account is None:
account = Account(canvas_id=str(canvas_user_id))
db.session.add(account)
account.email = email
account.name = name
account.canvas_courses = courses
account.is_global_admin = override_id is not None and str(override_id) in courses
# People added by staff or imported from a spreadsheet have a User (found
# by email) before they ever log in.
user = (
User.query.filter_by(canvas_id=canvas_user_id).one_or_none()
or User.query.filter_by(email=email).one_or_none()
)
if user is None:
user = User(email=email)
db.session.add(user)
user.canvas_id = canvas_user_id
user.email = email
user.name = name
user.canvas_courses = courses
user.is_global_admin = override_id is not None and str(override_id) in courses
db.session.commit()

login_user(account, remember=True)
login_user(user, remember=True)
session.permanent = True
# Keep only a short-lived access token, and only for staff, who need it to
# look up students; reauthorize when it expires.
session.pop("canvas_access_token", None)
session.pop("canvas_token_expires_at", None)
if account.is_global_admin or any(c["is_staff"] for c in courses.values()):
if user.is_global_admin or any(c["is_staff"] for c in courses.values()):
session["canvas_access_token"] = access_token
session["canvas_token_expires_at"] = time.time() + token.get("expires_in", 3600)


def course_user(account: Account):
"""The account's User row in the current course, created on first visit.
def sync_offering_role(user: User) -> bool:
"""Bring the user's role in the current offering in line with Canvas.

Staff and admin flags follow the account's Canvas roles from its last
sign-in. Returns None if the account isn't enrolled in the course.
Returns False if the user isn't enrolled in the offering's Canvas course.
"""
roles = account.roles_in(current_offering().canvas_id)
if user.is_global_admin:
return True
offering = current_offering()
roles = user.canvas_roles_in(offering.canvas_id)
if roles is None:
return None
return False
is_staff, is_admin = roles
course = get_course()
user = User.query.filter_by(email=account.email, course=course).one_or_none()
if user is None:
user = User(email=account.email, name=account.name, is_staff=is_staff,
is_admin=is_admin, course=course)
db.session.add(user)
elif (user.name, user.is_staff, user.is_admin) == (account.name, is_staff, is_admin):
return user
user.name = account.name
user.is_staff = is_staff
user.is_admin = is_admin
db.session.commit()
return user
if (offering.key in user.staff_offerings, offering.key in user.admin_offerings,
user.is_member_of(offering.key)) != (is_staff, is_admin, True):
user.set_role(offering.key, is_staff=is_staff, is_admin=is_admin)
db.session.commit()
return True


def create_login_client(app: flask.Flask):
Expand All @@ -98,12 +98,9 @@ def create_login_client(app: flask.Flask):

@login_manager.user_loader
def load_user(session_id):
account_id = Account.parse_session_id(session_id)
account = db.session.get(Account, account_id) if account_id else None
if account is None or not in_offering():
return account
user = course_user(account)
if user is None:
user_id = User.parse_session_id(session_id)
user = db.session.get(User, user_id) if user_id else None
if user is not None and in_offering() and not sync_offering_role(user):
abort(403, "You aren't enrolled in this course on bCourses. If you just "
"enrolled, sign out and sign in again.")
return user
Expand Down
7 changes: 3 additions & 4 deletions server/main.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import os

from flask import Flask
from flask_migrate import Migrate
from jinja2 import ChoiceLoader, FileSystemLoader
from werkzeug.middleware.proxy_fix import ProxyFix

Expand All @@ -24,6 +25,8 @@

load_config(app)
db.init_app(app)
# Schema changes are Alembic migrations in server/migrations; `flask db upgrade` applies them.
Migrate(app, db, directory=os.path.join(os.path.dirname(os.path.abspath(__file__)), "migrations"))
create_state_client(app)
create_login_client(app)
create_offering_pages(app)
Expand All @@ -34,9 +37,5 @@ def health():
return "ok"


with app.app_context():
db.create_all()


if __name__ == "__main__":
app.run(host="127.0.0.1", port=8000, debug=True)
1 change: 1 addition & 0 deletions server/migrations/README
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Single-database configuration for Flask.
50 changes: 50 additions & 0 deletions server/migrations/alembic.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# A generic, single database configuration.

[alembic]
# template used to generate migration files
# file_template = %%(rev)s_%%(slug)s

# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false


# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic,flask_migrate

[handlers]
keys = console

[formatters]
keys = generic

[logger_root]
level = WARN
handlers = console
qualname =

[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine

[logger_alembic]
level = INFO
handlers =
qualname = alembic

[logger_flask_migrate]
level = INFO
handlers =
qualname = flask_migrate

[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic

[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
Loading
Loading