From 35bd7a0e15bafa234e6c6eb8e1701b9fe0185b22 Mon Sep 17 00:00:00 2001 From: EarthenSky <24978329+EarthenSky@users.noreply.github.com> Date: Mon, 19 Aug 2024 15:22:10 -0700 Subject: [PATCH 01/11] add support for session type & endpoints for getting exams --- src/auth/crud.py | 13 +++++++++-- src/auth/tables.py | 8 ++++++- src/auth/types.py | 9 ++++++++ src/auth/urls.py | 24 +++++++++++++++++++-- src/exambank/urls.py | 45 +++++++++++++++++++++++++++++++++++++++ src/exambank/watermark.py | 23 +++++++++++--------- src/permission/types.py | 22 +++++++++++++++++++ 7 files changed, 129 insertions(+), 15 deletions(-) create mode 100644 src/auth/types.py create mode 100644 src/exambank/urls.py diff --git a/src/auth/crud.py b/src/auth/crud.py index bfad678..e22b44d 100644 --- a/src/auth/crud.py +++ b/src/auth/crud.py @@ -24,11 +24,12 @@ async def create_user_session(db_session: AsyncSession, session_id: str, computi # log this strange case _logger = logging.getLogger(__name__) _logger.warning(f"User session {session_id} exists for non-existent user {computing_id}!") + # create a user for this session new_user = SiteUser( computing_id=computing_id, first_logged_in=datetime.now(), - last_logged_in=datetime.now() + last_logged_in=datetime.now(), ) db_session.add(new_user) else: @@ -39,6 +40,8 @@ async def create_user_session(db_session: AsyncSession, session_id: str, computi issue_time=datetime.now(), session_id=session_id, computing_id=computing_id, + # TODO: check cas:authtype to determine this + session_type=SessionType.SFU, ) db_session.add(new_user_session) @@ -79,10 +82,16 @@ async def check_user_session(db_session: AsyncSession, session_id: str) -> dict: async def get_computing_id(db_session: AsyncSession, session_id: str) -> str | None: query = sqlalchemy.select(UserSession).where(UserSession.session_id == session_id) - existing_user_session = (await db_session.scalars(query)).first() + existing_user_session = await db_session.scalar(query) return existing_user_session.computing_id if existing_user_session else None +async def get_session_type(db_session: AsyncSession, session_id: str) -> str | None: + query = sqlalchemy.select(UserSession).where(UserSession.session_id == session_id) + existing_user_session = await db_session.scalar(query) + return existing_user_session.session_type if existing_user_session else None + + # remove all out of date user sessions async def task_clean_expired_user_sessions(db_session: AsyncSession) -> None: one_day_ago = datetime.now() - timedelta(days=0.5) diff --git a/src/auth/tables.py b/src/auth/tables.py index 56a2661..d57a74d 100644 --- a/src/auth/tables.py +++ b/src/auth/tables.py @@ -20,7 +20,12 @@ class UserSession(Base): session_id = Column( String(SESSION_ID_LEN), nullable=False, unique=True ) # the space needed to store 256 bytes in base64 - + + # TODO: create a migration for this + # whether a user is faculty, csss-member, student, or just "sfu" + session_type = Column( + String(SESSION_TYPE_LEN), nullable=False, + ) class SiteUser(Base): # user is a reserved word in postgres @@ -39,3 +44,4 @@ class SiteUser(Base): # note: default date (for pre-existing columns) is June 16th, 2024 first_logged_in = Column(DateTime, nullable=False, default=datetime(2024, 6, 16)) last_logged_in = Column(DateTime, nullable=False, default=datetime(2024, 6, 16)) + diff --git a/src/auth/types.py b/src/auth/types.py new file mode 100644 index 0000000..2f8719c --- /dev/null +++ b/src/auth/types.py @@ -0,0 +1,9 @@ +class SessionType: + # see: https://www.sfu.ca/information-systems/services/cas/cas-for-web-applications/ + # for more info on the kinds of members + FACULTY = "faculty" + CSSS_MEMBER = "csss member" # !cs-students maillist + STUDENT = "student" + ALUMNI = "alumni" + SFU = "sfu" + diff --git a/src/auth/urls.py b/src/auth/urls.py index 15fef99..9d90d05 100644 --- a/src/auth/urls.py +++ b/src/auth/urls.py @@ -57,10 +57,25 @@ async def login_user( _logger.info(f"User failed to login, with response {cas_response}") raise HTTPException(status_code=400, detail="authentication error, ticket likely invalid") - else: + elif "cas:authenticationSuccess" in cas_response["cas:serviceResponse"]: session_id = generate_session_id_b64(256) computing_id = cas_response["cas:serviceResponse"]["cas:authenticationSuccess"]["cas:user"] + # NOTE: it is the frontend's job to pass the correct authentication reuqest to CAS, otherwise we + # will only be able to give a user the "sfu" session_type (least privileged) + elif "cas:maillist" in cas_response["cas:serviceResponse"]: + # maillist + # TODO: (ASK SFU IT) can alumni be in the cmpt-students maillist? + if cas_response["cas:serviceResponse"]["cas:authenticationSuccess"]["cas:maillist"] == "cmpt-students": + session_type = SessionType.CSSS_MEMBER + else: + raise HTTPException(status_code=500, details="malformed authentication response; this is an SFU CAS error") + if "cas:authtype" in cas_response["cas:serviceResponse"]["cas:authenticationSuccess"]: + # sfu, alumni, faculty, student + session_type = cas_response["cas:serviceResponse"]["cas:authenticationSuccess"]["cas:authtype"] + else: + raise HTTPException(status_code=500, detail="malformed authentication response; this is an SFU CAS error") + await crud.create_user_session(db_session, session_id, computing_id) await db_session.commit() @@ -73,13 +88,17 @@ async def login_user( ) # this overwrites any past, possibly invalid, session_id return response + else: + raise HTTPException(status_code=500, detail="malformed authentication response; this is an SFU CAS error") + @router.get( "/check", description="Check if the current user is logged in based on session_id from cookies", ) async def check_authentication( - request: Request, # NOTE: these are the request headers + # the request headers + request: Request, db_session: database.DBSession, ): session_id = request.cookies.get("session_id", None) @@ -113,3 +132,4 @@ async def logout_user( response = JSONResponse(response_dict) response.delete_cookie(key="session_id") return response + diff --git a/src/exambank/urls.py b/src/exambank/urls.py new file mode 100644 index 0000000..e937b11 --- /dev/null +++ b/src/exambank/urls.py @@ -0,0 +1,45 @@ +from fastapi import APIRouter, HTTPException, Request + +import database + +from permission.types import ExamBankAccess +from exambank.watermark import raster_pdf_from_path + +EXAM_BANK_DIR = "/opt/csss-site/media/exam-bank" + +router = APIRouter( + prefix="/exam-bank", + tags=["exam-bank"], +) + +@router.get( + "/list" +) +async def all_exams( + request: Request, + db_session: database.DBSession, + course_name_starts_with: str, + exam_title_starts_with: str, +): + # TODO: implement this + pass + +@router.get( + "/" +) +async def get_exam( + request: Request, + db_session: database.DBSession, + course_name: str, + exam_title: str, +): + if not await ExamBankAccess.has_permission(request): + raise HTTPException(status_code=401, detail="user must have exam bank access permission") + + # TODO: implement this too + + # TODO: get list of files in dir & find the one we're looking for + #if title in EXAM_BANK_DIR: + + #raster_pdf_from_path() + diff --git a/src/exambank/watermark.py b/src/exambank/watermark.py index 7f124d6..562df80 100644 --- a/src/exambank/watermark.py +++ b/src/exambank/watermark.py @@ -12,8 +12,8 @@ BORDER = 20 def create_watermark( - computing_id: str, - density: int = 5 + computing_id: str, + density: int = 5 ) -> BytesIO: """ Returns a PDF with one page containing the watermark as text. @@ -50,6 +50,7 @@ def create_watermark( watermark_pdf = PdfWriter() stamp_pdf = PdfReader(stamp_buffer) warning_pdf = PdfReader(warning_buffer) + # Destructively merges in place stamp_pdf.pages[0].merge_page(warning_pdf.pages[0]) watermark_pdf.add_page(stamp_pdf.pages[0]) @@ -60,9 +61,9 @@ def create_watermark( return watermark_buffer def apply_watermark( - pdf_path: Path | str, - # expect a BytesIO instance (at position 0), accept a file/path - stamp: BytesIO | Path | str, + pdf_path: Path | str, + # expect a BytesIO instance (at position 0), accept a file/path + stamp: BytesIO | Path | str, ) -> BytesIO: # process file stamp_page = PdfReader(stamp).pages[0] @@ -77,12 +78,11 @@ def apply_watermark( watermarked_pdf = BytesIO() writer.write(watermarked_pdf) watermarked_pdf.seek(0) - return watermarked_pdf def raster_pdf( - pdf_path: BytesIO, - dpi: int = 300 + pdf_path: BytesIO, + dpi: int = 300 ) -> BytesIO: raster_buffer = BytesIO() # adapted from https://github.com/pymupdf/PyMuPDF/discussions/1183 @@ -97,14 +97,16 @@ def raster_pdf( tarpage.insert_image(tarpage.rect, stream=pix.pil_tobytes("PNG")) target.save(raster_buffer) + raster_buffer.seek(0) return raster_buffer def raster_pdf_from_path( - pdf_path: Path | str, - dpi: int = 300 + pdf_path: Path | str, + dpi: int = 300 ) -> BytesIO: raster_buffer = BytesIO() + # adapted from https://github.com/pymupdf/PyMuPDF/discussions/1183 with pymupdf.open(filename=pdf_path) as doc: page_count = doc.page_count @@ -117,5 +119,6 @@ def raster_pdf_from_path( tarpage.insert_image(tarpage.rect, stream=pix.pil_tobytes("PNG")) target.save(raster_buffer) + raster_buffer.seek(0) return raster_buffer diff --git a/src/permission/types.py b/src/permission/types.py index c9df28c..c4d0214 100644 --- a/src/permission/types.py +++ b/src/permission/types.py @@ -2,6 +2,8 @@ from typing import ClassVar import auth.crud +from auth.types import SessionType + import database import officers.crud from data.semesters import current_semester_start, step_semesters @@ -57,6 +59,7 @@ async def validate_request(db_session: database.DBSession, request: Request) -> Checks if the provided request satisfies these permissions, and raises the neccessary exceptions if not """ + # TODO: does this function return bool??? session_id = request.cookies.get("session_id", None) if session_id is None: raise HTTPException(status_code=401, detail="must be logged in") @@ -64,3 +67,22 @@ async def validate_request(db_session: database.DBSession, request: Request) -> computing_id = await auth.crud.get_computing_id(db_session, session_id) if not await WebsiteAdmin.has_permission(db_session, computing_id): raise HTTPException(status_code=401, detail="must have website admin permissions") + +class ExamBankAccess: + @staticmethod + async def has_permission( + db_session: database.DBSession, + request: Request, + ) -> bool: + session_id = request.cookies.get("session_id", None) + if session_id is None: + return False + + if await auth.crud.get_session_type(db_session, session_id) == SessionType.FACULTY: + return True + + # the only non-faculty who can view exams are website admins + computing_id = await auth.crud.get_computing_id(db_session, session_id) + return await WebsiteAdmin.has_permission(db_session, computing_id): + + From 35a20742d930e1bd074a5723981760c5bdbc4869 Mon Sep 17 00:00:00 2001 From: EarthenSky <24978329+EarthenSky@users.noreply.github.com> Date: Mon, 19 Aug 2024 16:53:39 -0700 Subject: [PATCH 02/11] add endpoint for generating pdf --- src/exambank/urls.py | 37 ++++++++++++++++++++++++++++++------- src/exambank/watermark.py | 3 ++- 2 files changed, 32 insertions(+), 8 deletions(-) diff --git a/src/exambank/urls.py b/src/exambank/urls.py index e937b11..1216023 100644 --- a/src/exambank/urls.py +++ b/src/exambank/urls.py @@ -18,8 +18,8 @@ async def all_exams( request: Request, db_session: database.DBSession, - course_name_starts_with: str, - exam_title_starts_with: str, + course_name_starts_with: Optional[str], + exam_title_starts_with: Optional[str], ): # TODO: implement this pass @@ -33,13 +33,36 @@ async def get_exam( course_name: str, exam_title: str, ): + session_id = request.cookies.get("session_id", None) + if session_id is None: + raise HTTPException(status_code=401) + + computing_id = await auth.crud.get_computing_id(db_session, session_id) + if computing_id is None: + raise HTTPException(status_code=401) + + # TODO: clean this checking into one function & one computing_id check if not await ExamBankAccess.has_permission(request): raise HTTPException(status_code=401, detail="user must have exam bank access permission") - - # TODO: implement this too - # TODO: get list of files in dir & find the one we're looking for - #if title in EXAM_BANK_DIR: + # TODO: store resource locations in a db table & simply look them up + + course_folders = [f.name for f in os.scandir(EXAM_BANK_DIR) if f.is_dir()] + if course_name in course_folders: + exams = [ + f.name[:-4] + for f in os.scandir(f"{EXAM_BANK_DIR}/{course_name}") + if f.is_file() and f.name.endswith(".pdf") + ] + if exam_title in exams: + # TODO: test this works nicely + exam_path = f"{EXAM_BANK_DIR}/{course_name}/{exam_title}.pdf" + watermark = create_watermark(computing_id, 20) + watermarked_pdf = apply_watermark(exam_path, watermark) + image_bytes = raster_pdf(watermarked_pdf) + + headers = { "Content-Disposition": f"inline; filename=\"{exam_title}_{computing_id}.pdf\"" } + return Response(content=image_bytes, headers=headers, media_type="application/pdf") - #raster_pdf_from_path() + raise HTTPException(status_code=400, detail="could not find the requested exam") diff --git a/src/exambank/watermark.py b/src/exambank/watermark.py index 562df80..97a06b8 100644 --- a/src/exambank/watermark.py +++ b/src/exambank/watermark.py @@ -1,5 +1,6 @@ from io import BytesIO from pathlib import Path +from datetime import datetime import pymupdf from pypdf import PdfReader, PdfWriter @@ -40,7 +41,6 @@ def create_watermark( warning_pdf.setFillColor(colors.grey, alpha=0.75) warning_pdf.setFont("Helvetica", 14) - from datetime import datetime warning_pdf.drawString(BORDER, BORDER, f"This exam was generated by {computing_id} at {datetime.now()}") warning_pdf.save() @@ -101,6 +101,7 @@ def raster_pdf( raster_buffer.seek(0) return raster_buffer +# TODO: not sure what this function does, but let's remove it? def raster_pdf_from_path( pdf_path: Path | str, dpi: int = 300 From ec407beffb621fa6ebd2a708c528fbc4b9987143 Mon Sep 17 00:00:00 2001 From: Gabe <24978329+EarthenSky@users.noreply.github.com> Date: Tue, 20 Aug 2024 09:53:44 -0700 Subject: [PATCH 03/11] Finalize basic exam endpoints Need to add tables & store exam metadata now (two tables) --- src/exambank/urls.py | 34 +++++++++++++++++++++++++++++++--- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/src/exambank/urls.py b/src/exambank/urls.py index 1216023..68f903d 100644 --- a/src/exambank/urls.py +++ b/src/exambank/urls.py @@ -13,7 +13,7 @@ ) @router.get( - "/list" + "/list/exams" ) async def all_exams( request: Request, @@ -21,8 +21,35 @@ async def all_exams( course_name_starts_with: Optional[str], exam_title_starts_with: Optional[str], ): - # TODO: implement this - pass + courses = [f.name for f in os.scandir(f"{EXAM_BANK_DIR}") if f.is_dir()] + if course_name_starts_with is not None: + courses = [course for course in courses if course.startswith(course_name_starts_with)] + + exams = [] + for course in courses: + for f in os.scandir(f"{EXAM_BANK_DIR}/{course}"): + if ( + f.is_file() and f.name.endswith(".pdf") + and (exam_title_starts_with is None + or name.startswith(exam_title_starts_with)) + ): + exams += [f.name] + + return JSONResponse(json.dumps(exams)) + +@router.get( + "/list/courses" +) +async def all_courses( + _request: Request, + _db_session: database.DBSession, + course_name_starts_with: Optional[str], +): + courses = [f.name for f in os.scandir(f"{EXAM_BANK_DIR}") if f.is_dir()] + if course_name_starts_with is not None: + courses = [course for course in courses if course.startswith(course_name_starts_with)] + + return JSONResponse(json.dumps(courses)) @router.get( "/" @@ -45,6 +72,7 @@ async def get_exam( if not await ExamBankAccess.has_permission(request): raise HTTPException(status_code=401, detail="user must have exam bank access permission") + # number exams with an exam_id pkey # TODO: store resource locations in a db table & simply look them up course_folders = [f.name for f in os.scandir(EXAM_BANK_DIR) if f.is_dir()] From 8a97ce255728a322a0a20f37b5a51ccd5362882f Mon Sep 17 00:00:00 2001 From: EarthenSky <24978329+EarthenSky@users.noreply.github.com> Date: Tue, 20 Aug 2024 13:45:45 -0700 Subject: [PATCH 04/11] fix linter bugs --- src/auth/crud.py | 11 ++++++--- src/auth/tables.py | 4 +-- src/auth/urls.py | 9 ++++--- src/constants.py | 3 +++ src/exambank/urls.py | 51 +++++++++++++++++++++------------------ src/exambank/watermark.py | 4 +-- src/permission/types.py | 7 +++--- 7 files changed, 52 insertions(+), 37 deletions(-) diff --git a/src/auth/crud.py b/src/auth/crud.py index e22b44d..deceddd 100644 --- a/src/auth/crud.py +++ b/src/auth/crud.py @@ -4,10 +4,16 @@ import sqlalchemy from auth.tables import SiteUser, UserSession +from auth.types import SessionType from sqlalchemy.ext.asyncio import AsyncSession -async def create_user_session(db_session: AsyncSession, session_id: str, computing_id: str) -> None: +async def create_user_session( + db_session: AsyncSession, + session_id: str, + computing_id: str, + session_type: str, +) -> None: """ Updates the past user session if one exists, so no duplicate sessions can ever occur. @@ -40,8 +46,7 @@ async def create_user_session(db_session: AsyncSession, session_id: str, computi issue_time=datetime.now(), session_id=session_id, computing_id=computing_id, - # TODO: check cas:authtype to determine this - session_type=SessionType.SFU, + session_type=session_type, ) db_session.add(new_user_session) diff --git a/src/auth/tables.py b/src/auth/tables.py index d57a74d..a4251ec 100644 --- a/src/auth/tables.py +++ b/src/auth/tables.py @@ -1,6 +1,6 @@ from datetime import datetime -from constants import COMPUTING_ID_LEN, SESSION_ID_LEN +from constants import COMPUTING_ID_LEN, SESSION_ID_LEN, SESSION_TYPE_LEN from database import Base from sqlalchemy import Column, DateTime, ForeignKey, String from sqlalchemy.orm import relationship @@ -20,7 +20,7 @@ class UserSession(Base): session_id = Column( String(SESSION_ID_LEN), nullable=False, unique=True ) # the space needed to store 256 bytes in base64 - + # TODO: create a migration for this # whether a user is faculty, csss-member, student, or just "sfu" session_type = Column( diff --git a/src/auth/urls.py b/src/auth/urls.py index 9d90d05..d9f03e0 100644 --- a/src/auth/urls.py +++ b/src/auth/urls.py @@ -7,6 +7,7 @@ import requests # TODO: make this async import xmltodict from auth import crud +from auth.types import SessionType from constants import root_ip_address from fastapi import APIRouter, BackgroundTasks, HTTPException, Request from fastapi.responses import JSONResponse, RedirectResponse @@ -63,20 +64,22 @@ async def login_user( # NOTE: it is the frontend's job to pass the correct authentication reuqest to CAS, otherwise we # will only be able to give a user the "sfu" session_type (least privileged) - elif "cas:maillist" in cas_response["cas:serviceResponse"]: + if "cas:maillist" in cas_response["cas:serviceResponse"]: # maillist # TODO: (ASK SFU IT) can alumni be in the cmpt-students maillist? if cas_response["cas:serviceResponse"]["cas:authenticationSuccess"]["cas:maillist"] == "cmpt-students": session_type = SessionType.CSSS_MEMBER else: raise HTTPException(status_code=500, details="malformed authentication response; this is an SFU CAS error") - if "cas:authtype" in cas_response["cas:serviceResponse"]["cas:authenticationSuccess"]: + elif "cas:authtype" in cas_response["cas:serviceResponse"]["cas:authenticationSuccess"]: # sfu, alumni, faculty, student session_type = cas_response["cas:serviceResponse"]["cas:authenticationSuccess"]["cas:authtype"] + if session_type not in SessionType.value_list(): + raise HTTPException(status_code=500, detail=f"unexpected session type from SFU CAS of {session_type}") else: raise HTTPException(status_code=500, detail="malformed authentication response; this is an SFU CAS error") - await crud.create_user_session(db_session, session_id, computing_id) + await crud.create_user_session(db_session, session_id, computing_id, session_type) await db_session.commit() # clean old sessions after sending the response diff --git a/src/constants.py b/src/constants.py index 1da1153..03940b6 100644 --- a/src/constants.py +++ b/src/constants.py @@ -9,6 +9,9 @@ COMPUTING_ID_LEN = 32 COMPUTING_ID_MAX = 8 +# depends how large SFU maillists can be +SESSION_TYPE_LEN = 32 + # see https://support.discord.com/hc/en-us/articles/4407571667351-How-to-Find-User-IDs-for-Law-Enforcement#:~:text=Each%20Discord%20user%20is%20assigned,user%20and%20cannot%20be%20changed. DISCORD_ID_LEN = 18 diff --git a/src/exambank/urls.py b/src/exambank/urls.py index 68f903d..554a4b8 100644 --- a/src/exambank/urls.py +++ b/src/exambank/urls.py @@ -1,9 +1,11 @@ -from fastapi import APIRouter, HTTPException, Request +import os +from typing import Optional +import auth.crud import database - +from exambank.watermark import apply_watermark, create_watermark, raster_pdf +from fastapi import APIRouter, HTTPException, JSONResponse, Request, Response from permission.types import ExamBankAccess -from exambank.watermark import raster_pdf_from_path EXAM_BANK_DIR = "/opt/csss-site/media/exam-bank" @@ -18,8 +20,8 @@ async def all_exams( request: Request, db_session: database.DBSession, - course_name_starts_with: Optional[str], - exam_title_starts_with: Optional[str], + course_name_starts_with: str | None, + exam_title_starts_with: str | None, ): courses = [f.name for f in os.scandir(f"{EXAM_BANK_DIR}") if f.is_dir()] if course_name_starts_with is not None: @@ -27,15 +29,15 @@ async def all_exams( exams = [] for course in courses: - for f in os.scandir(f"{EXAM_BANK_DIR}/{course}"): - if ( - f.is_file() and f.name.endswith(".pdf") - and (exam_title_starts_with is None - or name.startswith(exam_title_starts_with)) - ): - exams += [f.name] - - return JSONResponse(json.dumps(exams)) + exams += [ + f.name for f in os.scandir(f"{EXAM_BANK_DIR}/{course}") + if f.is_file() and f.name.endswith(".pdf") and ( + exam_title_starts_with is None + or f.name.startswith(exam_title_starts_with) + ) + ] + + return JSONResponse(exams) @router.get( "/list/courses" @@ -43,28 +45,31 @@ async def all_exams( async def all_courses( _request: Request, _db_session: database.DBSession, - course_name_starts_with: Optional[str], + course_name_starts_with: str | None, ): courses = [f.name for f in os.scandir(f"{EXAM_BANK_DIR}") if f.is_dir()] if course_name_starts_with is not None: - courses = [course for course in courses if course.startswith(course_name_starts_with)] - - return JSONResponse(json.dumps(courses)) + courses = [ + course for course in courses + if course.startswith(course_name_starts_with) + ] + + return JSONResponse(courses) @router.get( "/" ) async def get_exam( request: Request, - db_session: database.DBSession, + db_session: database.DBSession, course_name: str, exam_title: str, ): session_id = request.cookies.get("session_id", None) if session_id is None: raise HTTPException(status_code=401) - - computing_id = await auth.crud.get_computing_id(db_session, session_id) + + computing_id = await auth.crud.get_computing_id(db_session, session_id) if computing_id is None: raise HTTPException(status_code=401) @@ -78,7 +83,7 @@ async def get_exam( course_folders = [f.name for f in os.scandir(EXAM_BANK_DIR) if f.is_dir()] if course_name in course_folders: exams = [ - f.name[:-4] + f.name[:-4] for f in os.scandir(f"{EXAM_BANK_DIR}/{course_name}") if f.is_file() and f.name.endswith(".pdf") ] @@ -89,7 +94,7 @@ async def get_exam( watermarked_pdf = apply_watermark(exam_path, watermark) image_bytes = raster_pdf(watermarked_pdf) - headers = { "Content-Disposition": f"inline; filename=\"{exam_title}_{computing_id}.pdf\"" } + headers = { "Content-Disposition": f'inline; filename="{exam_title}_{computing_id}.pdf"' } return Response(content=image_bytes, headers=headers, media_type="application/pdf") raise HTTPException(status_code=400, detail="could not find the requested exam") diff --git a/src/exambank/watermark.py b/src/exambank/watermark.py index 97a06b8..a381f57 100644 --- a/src/exambank/watermark.py +++ b/src/exambank/watermark.py @@ -1,6 +1,6 @@ +from datetime import datetime from io import BytesIO from pathlib import Path -from datetime import datetime import pymupdf from pypdf import PdfReader, PdfWriter @@ -107,7 +107,7 @@ def raster_pdf_from_path( dpi: int = 300 ) -> BytesIO: raster_buffer = BytesIO() - + # adapted from https://github.com/pymupdf/PyMuPDF/discussions/1183 with pymupdf.open(filename=pdf_path) as doc: page_count = doc.page_count diff --git a/src/permission/types.py b/src/permission/types.py index c4d0214..1d26748 100644 --- a/src/permission/types.py +++ b/src/permission/types.py @@ -2,10 +2,9 @@ from typing import ClassVar import auth.crud -from auth.types import SessionType - import database import officers.crud +from auth.types import SessionType from data.semesters import current_semester_start, step_semesters from fastapi import HTTPException, Request from officers.constants import OfficerPosition @@ -79,10 +78,10 @@ async def has_permission( return False if await auth.crud.get_session_type(db_session, session_id) == SessionType.FACULTY: - return True + return True # the only non-faculty who can view exams are website admins computing_id = await auth.crud.get_computing_id(db_session, session_id) - return await WebsiteAdmin.has_permission(db_session, computing_id): + return await WebsiteAdmin.has_permission(db_session, computing_id) From 8dcea06e763e6b916fc814dc13c0e35fbfa94f51 Mon Sep 17 00:00:00 2001 From: EarthenSky <24978329+EarthenSky@users.noreply.github.com> Date: Tue, 20 Aug 2024 17:46:23 -0700 Subject: [PATCH 05/11] add tables & complete endpoints --- src/exambank/crud.py | 39 +++++++++++++++++++++++ src/exambank/tables.py | 55 +++++++++++++++++++++++++++++++++ src/exambank/urls.py | 70 ++++++++++++++++-------------------------- src/utils.py | 10 ++++++ 4 files changed, 131 insertions(+), 43 deletions(-) create mode 100644 src/exambank/crud.py create mode 100644 src/exambank/tables.py diff --git a/src/exambank/crud.py b/src/exambank/crud.py new file mode 100644 index 0000000..1c5a5c1 --- /dev/null +++ b/src/exambank/crud.py @@ -0,0 +1,39 @@ +from dataclasses import dataclass + +import database + + +@dataclass +class ExamMetadata: + exam_id: int + pdf_path: str + course_id: str + +async def create_exam(): + # TODO: these are for admins to run manually + pass + +async def update_exam(): + # TODO: these are for admins to run manually + pass + +async def all_exams( + db_session: database.DBSession, + course_starts_with: None | str, +): + # go through all exams (sorted by exam_id) & filter those which start with course_starts_with + # .like(f"%{course_starts_with}") + pass + +async def exam_metadata( + db_session: database.DBSession, + exam_id: int, +) -> ExamMetadata: + # TODO: implement this function + pass + +async def update_description(): + # TODO: implement this eventually, if we want students to contribute to + # the exam description + pass + diff --git a/src/exambank/tables.py b/src/exambank/tables.py new file mode 100644 index 0000000..7648179 --- /dev/null +++ b/src/exambank/tables.py @@ -0,0 +1,55 @@ +from datetime import datetime + +from constants import COMPUTING_ID_LEN, SESSION_ID_LEN, SESSION_TYPE_LEN +from database import Base +from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, Text +from sqlalchemy.orm import relationship + +# TODO: determine what info will need to be in the spreadsheet, then moved here + +# TODO: move this to types.py +class ExamKind: + FINAL = "final" + MIDTERM = "midterm" + QUIZ = "quiz" + ASSIGNMENT = "assignment" + NOTES = "notes" + MISC = "misc" + +class ExamMetadata(Base): + __tablename__ = "exam_metadata" + + exam_id = Column(Integer, primary_key=True, autoincrement=True) + upload_date = Column(DateTime, nullable=False) + # with EXAM_BANK_DIR as the root + pdf_path = Column(String(128), nullable=False) + + # formatted f"{faculty} {course_number}" + course_id = Column(String(16), nullable=True) + primary_author = Column(Integer, nullable=False) # foreign key constraint + title = Column(String(64), nullable=True) # Something like "Midterm 2" or "Computational Geometry Final" + # TODO: if this gets big, maybe separate it to a different table + description = Column(Text, nullable=True) # For a natural language description of the contents + kind = Column(String(16), nullable=False) + + # TODO: on the resulting output table, include xxxx-xx-xx for unknown dates + year = Column(Integer, nullable=True) + month = Column(Integer, nullable=True) + day = Column(Integer, nullable=True) + +class Professor(Base): + __tablename__ = "professor" + + professor_id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(String(64), nullable=False) + info_url = Column(String(128), nullable=False) + + computing_id = Column( + String(COMPUTING_ID_LEN), + # Foreign key constriant w/ users table + #ForeignKey("user_session.computing_id"), + nullable=True, + ) + +# TODO: eventually implement a table for courses & course info; hook it in with the rest of the site & coursys api + diff --git a/src/exambank/urls.py b/src/exambank/urls.py index 554a4b8..fc0917e 100644 --- a/src/exambank/urls.py +++ b/src/exambank/urls.py @@ -3,9 +3,11 @@ import auth.crud import database +import exambank.crud from exambank.watermark import apply_watermark, create_watermark, raster_pdf from fastapi import APIRouter, HTTPException, JSONResponse, Request, Response from permission.types import ExamBankAccess +from utils import path_in_dir EXAM_BANK_DIR = "/opt/csss-site/media/exam-bank" @@ -14,30 +16,22 @@ tags=["exam-bank"], ) +# TODO: update endpoints to use crud functions + @router.get( "/list/exams" ) async def all_exams( request: Request, db_session: database.DBSession, - course_name_starts_with: str | None, - exam_title_starts_with: str | None, + course_id_starts_with: str | None, ): courses = [f.name for f in os.scandir(f"{EXAM_BANK_DIR}") if f.is_dir()] - if course_name_starts_with is not None: - courses = [course for course in courses if course.startswith(course_name_starts_with)] - - exams = [] - for course in courses: - exams += [ - f.name for f in os.scandir(f"{EXAM_BANK_DIR}/{course}") - if f.is_file() and f.name.endswith(".pdf") and ( - exam_title_starts_with is None - or f.name.startswith(exam_title_starts_with) - ) - ] + if course_id_starts_with is not None: + courses = [course for course in courses if course.startswith(course_id_starts_with)] - return JSONResponse(exams) + exam_list = exambank.crud.all_exams(db_session, course_id_starts_with) + return JSONResponse([exam.serializable_dict() for exam in exam_list]) @router.get( "/list/courses" @@ -45,25 +39,18 @@ async def all_exams( async def all_courses( _request: Request, _db_session: database.DBSession, - course_name_starts_with: str | None, ): + # TODO: replace this with a table eventually courses = [f.name for f in os.scandir(f"{EXAM_BANK_DIR}") if f.is_dir()] - if course_name_starts_with is not None: - courses = [ - course for course in courses - if course.startswith(course_name_starts_with) - ] - return JSONResponse(courses) @router.get( - "/" + "/get/{exam_id}" ) async def get_exam( request: Request, db_session: database.DBSession, - course_name: str, - exam_title: str, + exam_id: int, ): session_id = request.cookies.get("session_id", None) if session_id is None: @@ -80,22 +67,19 @@ async def get_exam( # number exams with an exam_id pkey # TODO: store resource locations in a db table & simply look them up - course_folders = [f.name for f in os.scandir(EXAM_BANK_DIR) if f.is_dir()] - if course_name in course_folders: - exams = [ - f.name[:-4] - for f in os.scandir(f"{EXAM_BANK_DIR}/{course_name}") - if f.is_file() and f.name.endswith(".pdf") - ] - if exam_title in exams: - # TODO: test this works nicely - exam_path = f"{EXAM_BANK_DIR}/{course_name}/{exam_title}.pdf" - watermark = create_watermark(computing_id, 20) - watermarked_pdf = apply_watermark(exam_path, watermark) - image_bytes = raster_pdf(watermarked_pdf) - - headers = { "Content-Disposition": f'inline; filename="{exam_title}_{computing_id}.pdf"' } - return Response(content=image_bytes, headers=headers, media_type="application/pdf") - - raise HTTPException(status_code=400, detail="could not find the requested exam") + meta = exambank.crud.exam_metadata(db_session, exam_id) + if meta is None: + raise HTTPException(status_code=400, detail=f"could not find the exam with exam_id={exam_id}") + + exam_path = f"{EXAM_BANK_DIR}/{meta.pdf_path}" + if not path_in_dir(exam_path, EXAM_BANK_DIR): + raise HTTPException(status_code=500, detail="Found dangerous pdf path, exiting") + + # TODO: test this works nicely + watermark = create_watermark(computing_id, 20) + watermarked_pdf = apply_watermark(exam_path, watermark) + image_bytes = raster_pdf(watermarked_pdf) + + headers = { "Content-Disposition": f'inline; filename="{meta.course_id}_{exam_id}_{computing_id}.pdf"' } + return Response(content=image_bytes, headers=headers, media_type="application/pdf") diff --git a/src/utils.py b/src/utils.py index 8f2b8cb..cae0f9c 100644 --- a/src/utils.py +++ b/src/utils.py @@ -28,3 +28,13 @@ def is_active_officer(query: Select) -> Select: ) ) ) + + +def path_in_dir(path: str, parent_dir: str): + """ + Determine if path is in parent_dir. A useful check for input + validation, to avoid leaking secrets + """ + parent = Path(parent_dir).resolve() + child = Path(path).resolve() + return root in child.parents From 5ee46a96c471a634aad1aea8b7a756c4e4575928 Mon Sep 17 00:00:00 2001 From: Gabe WSL Debian <24978329+EarthenSky@users.noreply.github.com> Date: Tue, 31 Dec 2024 14:04:50 -0800 Subject: [PATCH 06/11] fix linter issues --- src/auth/crud.py | 1 - src/exambank/tables.py | 5 +++-- src/exambank/urls.py | 3 ++- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/auth/crud.py b/src/auth/crud.py index 05892a4..d7dfe1c 100644 --- a/src/auth/crud.py +++ b/src/auth/crud.py @@ -50,7 +50,6 @@ async def create_user_session( existing_user = (await db_session.scalars(query)).first() if existing_user is None: # log this strange case - _logger = logging.getLogger(__name__) _logger.warning(f"User session {session_id} exists for non-existent user {computing_id}!") # create a user for this session diff --git a/src/exambank/tables.py b/src/exambank/tables.py index 7648179..1b02bb7 100644 --- a/src/exambank/tables.py +++ b/src/exambank/tables.py @@ -1,10 +1,11 @@ from datetime import datetime -from constants import COMPUTING_ID_LEN, SESSION_ID_LEN, SESSION_TYPE_LEN -from database import Base from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, Text from sqlalchemy.orm import relationship +from constants import COMPUTING_ID_LEN, SESSION_ID_LEN, SESSION_TYPE_LEN +from database import Base + # TODO: determine what info will need to be in the spreadsheet, then moved here # TODO: move this to types.py diff --git a/src/exambank/urls.py b/src/exambank/urls.py index fc0917e..06cfc9c 100644 --- a/src/exambank/urls.py +++ b/src/exambank/urls.py @@ -1,11 +1,12 @@ import os from typing import Optional +from fastapi import APIRouter, HTTPException, JSONResponse, Request, Response + import auth.crud import database import exambank.crud from exambank.watermark import apply_watermark, create_watermark, raster_pdf -from fastapi import APIRouter, HTTPException, JSONResponse, Request, Response from permission.types import ExamBankAccess from utils import path_in_dir From 1d6d5ae34d0f014e9db0891ab8ac8f8d041ad32d Mon Sep 17 00:00:00 2001 From: Gabe WSL Debian <24978329+EarthenSky@users.noreply.github.com> Date: Tue, 31 Dec 2024 14:15:30 -0800 Subject: [PATCH 07/11] clean create_user_session and add session type to endpoints --- src/auth/crud.py | 28 ++-------------------------- src/load_test_db.py | 11 ++++++----- 2 files changed, 8 insertions(+), 31 deletions(-) diff --git a/src/auth/crud.py b/src/auth/crud.py index d7dfe1c..3ba4dcb 100644 --- a/src/auth/crud.py +++ b/src/auth/crud.py @@ -5,7 +5,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from auth.tables import SiteUser, UserSession -from auth.types import SessionType, SiteUserData +from auth.types import SiteUserData _logger = logging.getLogger(__name__) @@ -46,20 +46,7 @@ async def create_user_session( if existing_user_session is not None: existing_user_session.issue_time = datetime.now() existing_user_session.session_id = session_id - query = sqlalchemy.select(SiteUser).where(SiteUser.computing_id == computing_id) - existing_user = (await db_session.scalars(query)).first() - if existing_user is None: - # log this strange case - _logger.warning(f"User session {session_id} exists for non-existent user {computing_id}!") - - # create a user for this session - new_user = SiteUser( - computing_id=computing_id, - first_logged_in=datetime.now(), - last_logged_in=datetime.now(), - ) - db_session.add(new_user) - else: + if existing_user is not None: # update the last time the user logged in to now existing_user.last_logged_in = datetime.now() else: @@ -70,17 +57,6 @@ async def create_user_session( session_type=session_type, )) - # add new user to User table if it's their first time logging in - query = sqlalchemy.select(SiteUser).where(SiteUser.computing_id == computing_id) - existing_user = (await db_session.scalars(query)).first() - if existing_user is None: - new_user = SiteUser( - computing_id=computing_id, - first_logged_in=datetime.now(), - last_logged_in=datetime.now() - ) - db_session.add(new_user) - async def remove_user_session(db_session: AsyncSession, session_id: str) -> dict: query = sqlalchemy.select(UserSession).where(UserSession.session_id == session_id) diff --git a/src/load_test_db.py b/src/load_test_db.py index b9bc596..84eee1e 100644 --- a/src/load_test_db.py +++ b/src/load_test_db.py @@ -9,6 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from auth.crud import create_user_session, update_site_user +from auth.types import SessionType from database import SQLALCHEMY_TEST_DATABASE_URL, Base, DatabaseSessionManager from officers.constants import OfficerPosition from officers.crud import create_new_officer_info, create_new_officer_term, update_officer_info, update_officer_term @@ -58,15 +59,15 @@ async def reset_db(engine): print(f"new tables: {table_list}") async def load_test_auth_data(db_session: AsyncSession): - await create_user_session(db_session, "temp_id_314", "abc314") + await create_user_session(db_session, "temp_id_314", "abc314", SessionType.SFU) await update_site_user(db_session, "temp_id_314", "www.my_profile_picture_url.ca/test") await db_session.commit() async def load_test_officers_data(db_session: AsyncSession): print("login the 3 users, putting them in the site users table") - await create_user_session(db_session, "temp_id_1", "abc11") - await create_user_session(db_session, "temp_id_2", "abc22") - await create_user_session(db_session, "temp_id_3", "abc33") + await create_user_session(db_session, "temp_id_1", "abc11", SessionType.SFU) + await create_user_session(db_session, "temp_id_2", "abc22", SessionType.SFU) + await create_user_session(db_session, "temp_id_3", "abc33", SessionType.FACULTY) await db_session.commit() print("add officer info") @@ -216,7 +217,7 @@ async def load_test_officers_data(db_session: AsyncSession): async def load_sysadmin(db_session: AsyncSession): # put your computing id here for testing purposes print(f"loading new sysadmin '{SYSADMIN_COMPUTING_ID}'") - await create_user_session(db_session, f"temp_id_{SYSADMIN_COMPUTING_ID}", SYSADMIN_COMPUTING_ID) + await create_user_session(db_session, f"temp_id_{SYSADMIN_COMPUTING_ID}", SYSADMIN_COMPUTING_ID, SessionType.CSSS_MEMBER) await create_new_officer_info(db_session, OfficerInfo( legal_name="Gabe Schulz", discord_id=None, From aa43e384794482a653be322e6561e9e7874e856c Mon Sep 17 00:00:00 2001 From: Gabe WSL Debian <24978329+EarthenSky@users.noreply.github.com> Date: Tue, 31 Dec 2024 14:19:41 -0800 Subject: [PATCH 08/11] clean query formatting in auth module --- src/auth/crud.py | 40 ++++++++++++++++++------------ tests/integration/test_officers.py | 3 ++- 2 files changed, 26 insertions(+), 17 deletions(-) diff --git a/src/auth/crud.py b/src/auth/crud.py index 3ba4dcb..0c0c0a5 100644 --- a/src/auth/crud.py +++ b/src/auth/crud.py @@ -59,20 +59,29 @@ async def create_user_session( async def remove_user_session(db_session: AsyncSession, session_id: str) -> dict: - query = sqlalchemy.select(UserSession).where(UserSession.session_id == session_id) - user_session = await db_session.scalars(query) + user_session = await db_session.scalars( + sqlalchemy + .select(UserSession) + .where(UserSession.session_id == session_id) + ) await db_session.delete(user_session.first()) async def get_computing_id(db_session: AsyncSession, session_id: str) -> str | None: - query = sqlalchemy.select(UserSession).where(UserSession.session_id == session_id) - existing_user_session = await db_session.scalar(query) + existing_user_session = await db_session.scalar( + sqlalchemy + .select(UserSession) + .where(UserSession.session_id == session_id) + ) return existing_user_session.computing_id if existing_user_session else None async def get_session_type(db_session: AsyncSession, session_id: str) -> str | None: - query = sqlalchemy.select(UserSession).where(UserSession.session_id == session_id) - existing_user_session = await db_session.scalar(query) + existing_user_session = await db_session.scalar( + sqlalchemy + .select(UserSession) + .where(UserSession.session_id == session_id) + ) return existing_user_session.session_type if existing_user_session else None @@ -80,28 +89,29 @@ async def get_session_type(db_session: AsyncSession, session_id: str) -> str | N async def task_clean_expired_user_sessions(db_session: AsyncSession): one_day_ago = datetime.now() - timedelta(days=0.5) - query = sqlalchemy.delete(UserSession).where(UserSession.issue_time < one_day_ago) - await db_session.execute(query) + await db_session.execute( + sqlalchemy + .delete(UserSession) + .where(UserSession.issue_time < one_day_ago) + ) await db_session.commit() # get the site user given a session ID; returns None when session is invalid async def get_site_user(db_session: AsyncSession, session_id: str) -> None | SiteUserData: - query = ( + user_session = await db_session.scalar( sqlalchemy .select(UserSession) .where(UserSession.session_id == session_id) ) - user_session = await db_session.scalar(query) if user_session is None: return None - query = ( + user = await db_session.scalar( sqlalchemy .select(SiteUser) .where(SiteUser.computing_id == user_session.computing_id) ) - user = await db_session.scalar(query) if user is None: return None @@ -128,21 +138,19 @@ async def update_site_user( session_id: str, profile_picture_url: str ) -> bool: - query = ( + user_session = await db_session.scalar( sqlalchemy .select(UserSession) .where(UserSession.session_id == session_id) ) - user_session = await db_session.scalar(query) if user_session is None: return False - query = ( + await db_session.execute( sqlalchemy .update(SiteUser) .where(SiteUser.computing_id == user_session.computing_id) .values(profile_picture_url = profile_picture_url) ) - await db_session.execute(query) return True diff --git a/tests/integration/test_officers.py b/tests/integration/test_officers.py index dd8ba0c..8d357b6 100644 --- a/tests/integration/test_officers.py +++ b/tests/integration/test_officers.py @@ -7,6 +7,7 @@ import load_test_db from auth.crud import create_user_session +from auth.types import SessionType from database import SQLALCHEMY_TEST_DATABASE_URL, DatabaseSessionManager from main import app from officers.constants import OfficerPosition @@ -169,7 +170,7 @@ async def test__endpoints_admin(client, database_setup): # login as website admin session_id = "temp_id_" + load_test_db.SYSADMIN_COMPUTING_ID async with database_setup.session() as db_session: - await create_user_session(db_session, session_id, load_test_db.SYSADMIN_COMPUTING_ID) + await create_user_session(db_session, session_id, load_test_db.SYSADMIN_COMPUTING_ID, SessionType.CSSS_MEMBER) client.cookies = { "session_id": session_id } From c253fe1b71a148dc931f09d81edeb0992770bddb Mon Sep 17 00:00:00 2001 From: Gabe WSL Debian <24978329+EarthenSky@users.noreply.github.com> Date: Wed, 1 Jan 2025 17:32:56 -0800 Subject: [PATCH 09/11] small changes --- src/auth/tables.py | 1 - src/auth/types.py | 1 + src/constants.py | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/auth/tables.py b/src/auth/tables.py index fa715c2..041d6d1 100644 --- a/src/auth/tables.py +++ b/src/auth/tables.py @@ -44,6 +44,5 @@ class SiteUser(Base): first_logged_in = Column(DateTime, nullable=False, default=datetime(2024, 6, 16)) last_logged_in = Column(DateTime, nullable=False, default=datetime(2024, 6, 16)) - # TODO: is this still being used? # optional user information for display purposes profile_picture_url = Column(Text, nullable=True) diff --git a/src/auth/types.py b/src/auth/types.py index 1dd80e7..8e2e609 100644 --- a/src/auth/types.py +++ b/src/auth/types.py @@ -5,6 +5,7 @@ class SessionType: # see: https://www.sfu.ca/information-systems/services/cas/cas-for-web-applications/ # for more info on the kinds of members FACULTY = "faculty" + # TODO: what will happen to the maillists for authentication; are groups part of this? CSSS_MEMBER = "csss member" # !cs-students maillist STUDENT = "student" ALUMNI = "alumni" diff --git a/src/constants.py b/src/constants.py index aebde4d..49dc571 100644 --- a/src/constants.py +++ b/src/constants.py @@ -15,7 +15,7 @@ COMPUTING_ID_MAX = 8 # depends how large SFU maillists can be -SESSION_TYPE_LEN = 32 +SESSION_TYPE_LEN = 48 # see https://support.discord.com/hc/en-us/articles/4407571667351-How-to-Find-User-IDs-for-Law-Enforcement#:~:text=Each%20Discord%20user%20is%20assigned,user%20and%20cannot%20be%20changed. # NOTE: the length got updated to 19 in july 2024. See https://www.reddit.com/r/discordapp/comments/ucrp1r/only_3_months_until_discord_ids_hit_19_digits/ From 368e7bc901d4312f18045ffb4b15bd531e0f4487 Mon Sep 17 00:00:00 2001 From: Gabe WSL Debian <24978329+EarthenSky@users.noreply.github.com> Date: Thu, 2 Jan 2025 01:12:34 -0800 Subject: [PATCH 10/11] adjust CAS api usage & do some slight refactoring --- src/auth/types.py | 10 ++++++++++ src/auth/urls.py | 17 +++++++++++++---- src/auth/utils.py | 20 ++++++++++++++++++++ src/exambank/crud.py | 6 ++++-- src/exambank/tables.py | 20 ++++++-------------- src/exambank/types.py | 7 +++++++ src/exambank/urls.py | 34 ++++++---------------------------- src/officers/urls.py | 16 +--------------- src/permission/types.py | 22 ++++++++++++++-------- 9 files changed, 81 insertions(+), 71 deletions(-) create mode 100644 src/auth/utils.py create mode 100644 src/exambank/types.py diff --git a/src/auth/types.py b/src/auth/types.py index 8e2e609..598724b 100644 --- a/src/auth/types.py +++ b/src/auth/types.py @@ -11,6 +11,16 @@ class SessionType: ALUMNI = "alumni" SFU = "sfu" + @staticmethod + def valid_session_type_list(): + # values taken from https://www.sfu.ca/information-systems/services/cas/cas-for-web-applications.html + return [ + "faculty", + "student", + "alumni", + "sfu" + ] + @dataclass class SiteUserData: computing_id: str diff --git a/src/auth/urls.py b/src/auth/urls.py index 24070df..6c75f08 100644 --- a/src/auth/urls.py +++ b/src/auth/urls.py @@ -47,7 +47,11 @@ async def login_user( # verify the ticket is valid service = urllib.parse.quote(f"{FRONTEND_ROOT_URL}/api/auth/login?redirect_path={redirect_path}&redirect_fragment={redirect_fragment}") service_validate_url = f"https://cas.sfu.ca/cas/serviceValidate?service={service}&ticket={ticket}" - cas_response = xmltodict.parse(requests.get(service_validate_url).text) + cas_response_text = requests.get(service_validate_url).text + cas_response = xmltodict.parse(cas_response_text) + + print("CAS RESPONSE ::") + print(cas_response_text) if "cas:authenticationFailure" in cas_response["cas:serviceResponse"]: _logger.info(f"User failed to login, with response {cas_response}") @@ -65,14 +69,19 @@ async def login_user( if cas_response["cas:serviceResponse"]["cas:authenticationSuccess"]["cas:maillist"] == "cmpt-students": session_type = SessionType.CSSS_MEMBER else: - raise HTTPException(status_code=500, details="malformed authentication response; this is an SFU CAS error") + raise HTTPException(status_code=500, details="malformed cas:maillist authentication response; this is an SFU CAS error") elif "cas:authtype" in cas_response["cas:serviceResponse"]["cas:authenticationSuccess"]: # sfu, alumni, faculty, student session_type = cas_response["cas:serviceResponse"]["cas:authenticationSuccess"]["cas:authtype"] - if session_type not in SessionType.value_list(): + if session_type not in SessionType.valid_session_type_list(): raise HTTPException(status_code=500, detail=f"unexpected session type from SFU CAS of {session_type}") + + if session_type == "alumni": + if "@" not in computing_id: + raise HTTPException(status_code=500, detail=f"invalid alumni computing_id response from CAS AUTH with value {session_type}") + computing_id = computing_id.split("@")[0] else: - raise HTTPException(status_code=500, detail="malformed authentication response; this is an SFU CAS error") + raise HTTPException(status_code=500, detail="malformed unknown authentication response; this is an SFU CAS error") await crud.create_user_session(db_session, session_id, computing_id, session_type) await db_session.commit() diff --git a/src/auth/utils.py b/src/auth/utils.py new file mode 100644 index 0000000..ea86a8d --- /dev/null +++ b/src/auth/utils.py @@ -0,0 +1,20 @@ +from fastapi import HTTPException, Request + +import auth.crud +import database + + +async def logged_in_or_raise( + request: Request, + db_session: database.DBSession +) -> tuple[str, str]: + """gets the user's computing_id, or raises an exception if the current request is not logged in""" + session_id = request.cookies.get("session_id", None) + if session_id is None: + raise HTTPException(status_code=401) + + session_computing_id = await auth.crud.get_computing_id(db_session, session_id) + if session_computing_id is None: + raise HTTPException(status_code=401) + + return session_id, session_computing_id diff --git a/src/exambank/crud.py b/src/exambank/crud.py index 1c5a5c1..09cbac7 100644 --- a/src/exambank/crud.py +++ b/src/exambank/crud.py @@ -10,11 +10,13 @@ class ExamMetadata: course_id: str async def create_exam(): - # TODO: these are for admins to run manually + # for admins to run manually + # TODO: implement this later; for now just upload data manually pass async def update_exam(): - # TODO: these are for admins to run manually + # for admins to run manually + # TODO: implement this later; for now just upload data manually pass async def all_exams( diff --git a/src/exambank/tables.py b/src/exambank/tables.py index 1b02bb7..8f43570 100644 --- a/src/exambank/tables.py +++ b/src/exambank/tables.py @@ -1,4 +1,5 @@ from datetime import datetime +from types import ExamKind from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, Text from sqlalchemy.orm import relationship @@ -8,15 +9,6 @@ # TODO: determine what info will need to be in the spreadsheet, then moved here -# TODO: move this to types.py -class ExamKind: - FINAL = "final" - MIDTERM = "midterm" - QUIZ = "quiz" - ASSIGNMENT = "assignment" - NOTES = "notes" - MISC = "misc" - class ExamMetadata(Base): __tablename__ = "exam_metadata" @@ -41,16 +33,16 @@ class ExamMetadata(Base): class Professor(Base): __tablename__ = "professor" - professor_id = Column(Integer, primary_key=True, autoincrement=True) - name = Column(String(64), nullable=False) - info_url = Column(String(128), nullable=False) - computing_id = Column( String(COMPUTING_ID_LEN), + ForeignKey("user_session.computing_id"), + primary_key=True, # Foreign key constriant w/ users table - #ForeignKey("user_session.computing_id"), nullable=True, ) + name = Column(String(64), nullable=False) + info_url = Column(String(128), nullable=False) + # TODO: eventually implement a table for courses & course info; hook it in with the rest of the site & coursys api diff --git a/src/exambank/types.py b/src/exambank/types.py new file mode 100644 index 0000000..c68cc49 --- /dev/null +++ b/src/exambank/types.py @@ -0,0 +1,7 @@ +class ExamKind: + FINAL = "final" + MIDTERM = "midterm" + QUIZ = "quiz" + ASSIGNMENT = "assignment" + NOTES = "notes" + MISC = "misc" diff --git a/src/exambank/urls.py b/src/exambank/urls.py index 06cfc9c..18940eb 100644 --- a/src/exambank/urls.py +++ b/src/exambank/urls.py @@ -1,11 +1,10 @@ import os -from typing import Optional from fastapi import APIRouter, HTTPException, JSONResponse, Request, Response -import auth.crud import database import exambank.crud +from auth.utils import logged_in_or_raise from exambank.watermark import apply_watermark, create_watermark, raster_pdf from permission.types import ExamBankAccess from utils import path_in_dir @@ -17,7 +16,7 @@ tags=["exam-bank"], ) -# TODO: update endpoints to use crud functions +# TODO: update endpoints to use crud functions -> don't use crud actually; refactor to do that later @router.get( "/list/exams" @@ -34,17 +33,6 @@ async def all_exams( exam_list = exambank.crud.all_exams(db_session, course_id_starts_with) return JSONResponse([exam.serializable_dict() for exam in exam_list]) -@router.get( - "/list/courses" -) -async def all_courses( - _request: Request, - _db_session: database.DBSession, -): - # TODO: replace this with a table eventually - courses = [f.name for f in os.scandir(f"{EXAM_BANK_DIR}") if f.is_dir()] - return JSONResponse(courses) - @router.get( "/get/{exam_id}" ) @@ -53,17 +41,8 @@ async def get_exam( db_session: database.DBSession, exam_id: int, ): - session_id = request.cookies.get("session_id", None) - if session_id is None: - raise HTTPException(status_code=401) - - computing_id = await auth.crud.get_computing_id(db_session, session_id) - if computing_id is None: - raise HTTPException(status_code=401) - - # TODO: clean this checking into one function & one computing_id check - if not await ExamBankAccess.has_permission(request): - raise HTTPException(status_code=401, detail="user must have exam bank access permission") + _, session_computing_id = await logged_in_or_raise(request, db_session) + await ExamBankAccess.has_permission_or_raise(request, errmsg="user must have exam bank access permission") # number exams with an exam_id pkey # TODO: store resource locations in a db table & simply look them up @@ -77,10 +56,9 @@ async def get_exam( raise HTTPException(status_code=500, detail="Found dangerous pdf path, exiting") # TODO: test this works nicely - watermark = create_watermark(computing_id, 20) + watermark = create_watermark(session_computing_id, 20) watermarked_pdf = apply_watermark(exam_path, watermark) image_bytes = raster_pdf(watermarked_pdf) - headers = { "Content-Disposition": f'inline; filename="{meta.course_id}_{exam_id}_{computing_id}.pdf"' } + headers = { "Content-Disposition": f'inline; filename="{meta.course_id}_{exam_id}_{session_computing_id}.pdf"' } return Response(content=image_bytes, headers=headers, media_type="application/pdf") - diff --git a/src/officers/urls.py b/src/officers/urls.py index decdb10..901cafc 100755 --- a/src/officers/urls.py +++ b/src/officers/urls.py @@ -7,6 +7,7 @@ import database import officers.crud import utils +from auth.utils import logged_in_or_raise from officers.tables import OfficerInfo, OfficerTerm from officers.types import InitialOfficerInfo, OfficerInfoUpload, OfficerTermUpload from permission.types import OfficerPrivateInfo, WebsiteAdmin @@ -37,21 +38,6 @@ async def has_officer_private_info_access( has_private_access = await OfficerPrivateInfo.has_permission(db_session, computing_id) return session_id, computing_id, has_private_access -async def logged_in_or_raise( - request: Request, - db_session: database.DBSession -) -> tuple[str, str]: - """gets the user's computing_id, or raises an exception if the current request is not logged in""" - session_id = request.cookies.get("session_id", None) - if session_id is None: - raise HTTPException(status_code=401) - - session_computing_id = await auth.crud.get_computing_id(db_session, session_id) - if session_computing_id is None: - raise HTTPException(status_code=401) - - return session_id, session_computing_id - # ---------------------------------------- # # endpoints diff --git a/src/permission/types.py b/src/permission/types.py index 6e2f25e..6766dff 100644 --- a/src/permission/types.py +++ b/src/permission/types.py @@ -51,19 +51,18 @@ async def has_permission(db_session: database.DBSession, computing_id: str) -> b return False @staticmethod - async def validate_request(db_session: database.DBSession, request: Request) -> bool: + async def validate_request(db_session: database.DBSession, request: Request): """ Checks if the provided request satisfies these permissions, and raises the neccessary exceptions if not """ - # TODO: does this function return bool??? session_id = request.cookies.get("session_id", None) if session_id is None: raise HTTPException(status_code=401, detail="must be logged in") - else: - computing_id = await auth.crud.get_computing_id(db_session, session_id) - if not await WebsiteAdmin.has_permission(db_session, computing_id): - raise HTTPException(status_code=401, detail="must have website admin permissions") + + computing_id = await auth.crud.get_computing_id(db_session, session_id) + if not await WebsiteAdmin.has_permission(db_session, computing_id): + raise HTTPException(status_code=401, detail="must have website admin permissions") @staticmethod async def has_permission_or_raise( @@ -84,11 +83,18 @@ async def has_permission( if session_id is None: return False - # TODO: allow CSSS officers to access the exam bank, in addition to faculty - if await auth.crud.get_session_type(db_session, session_id) == SessionType.FACULTY: return True # the only non-faculty who can view exams are website admins computing_id = await auth.crud.get_computing_id(db_session, session_id) return await WebsiteAdmin.has_permission(db_session, computing_id) + + @staticmethod + async def has_permission_or_raise( + db_session: database.DBSession, + request: Request, + errmsg: str = "must have exam bank access permissions" + ): + if not await ExamBankAccess.has_permission(db_session, request): + raise HTTPException(status_code=401, detail=errmsg) From 695b75130be2497e46a32f0474ad8e575663ad36 Mon Sep 17 00:00:00 2001 From: Gabe WSL Debian <24978329+EarthenSky@users.noreply.github.com> Date: Thu, 2 Jan 2025 16:38:47 -0800 Subject: [PATCH 11/11] add migrations, update tables, and update endpoint sketches --- .../2f1b67c68ba5_add_exam_bank_tables.py | 60 +++++++++++++++++++ .../3f19883760ae_add_session_type_to_auth.py | 25 ++++++++ src/auth/tables.py | 5 +- src/exambank/crud.py | 41 ------------- src/exambank/tables.py | 57 ++++++++++-------- src/exambank/urls.py | 32 ++++++++-- 6 files changed, 146 insertions(+), 74 deletions(-) create mode 100644 src/alembic/versions/2f1b67c68ba5_add_exam_bank_tables.py create mode 100644 src/alembic/versions/3f19883760ae_add_session_type_to_auth.py delete mode 100644 src/exambank/crud.py diff --git a/src/alembic/versions/2f1b67c68ba5_add_exam_bank_tables.py b/src/alembic/versions/2f1b67c68ba5_add_exam_bank_tables.py new file mode 100644 index 0000000..72e8c15 --- /dev/null +++ b/src/alembic/versions/2f1b67c68ba5_add_exam_bank_tables.py @@ -0,0 +1,60 @@ +"""add exam bank tables + +Revision ID: 2f1b67c68ba5 +Revises: 3f19883760ae +Create Date: 2025-01-03 00:24:44.608869 + +""" +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "2f1b67c68ba5" +down_revision: str | None = "3f19883760ae" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table( + "professor", + sa.Column("professor_id", sa.Integer, primary_key=True, autoincrement=True), + sa.Column("name", sa.String(128), nullable=False), + sa.Column("info_url", sa.String(128), nullable=False), + sa.Column("computing_id", sa.String(32), sa.ForeignKey("user_session.computing_id"), nullable=True), + ) + + op.create_table( + "course", + sa.Column("course_id", sa.Integer, primary_key=True, autoincrement=True), + sa.Column("course_faculty", sa.String(12), nullable=False), + sa.Column("course_number", sa.String(12), nullable=False), + sa.Column("course_name", sa.String(96), nullable=False), + ) + + op.create_table( + "exam_metadata", + sa.Column("exam_id", sa.Integer, primary_key=True), + sa.Column("upload_date", sa.DateTime, nullable=False), + sa.Column("exam_pdf_size", sa.Integer, nullable=False), + + sa.Column("author_id", sa.String(32), sa.ForeignKey("professor.professor_id"), nullable=False), + sa.Column("author_confirmed", sa.Boolean, nullable=False), + sa.Column("author_permission", sa.Boolean, nullable=False), + + sa.Column("kind", sa.String(24), nullable=False), + sa.Column("course_id", sa.String(32), sa.ForeignKey("professor.professor_id"), nullable=True), + sa.Column("title", sa.String(96), nullable=True), + sa.Column("description", sa.Text, nullable=True), + + sa.Column("date_string", sa.String(10), nullable=False), + ) + + +def downgrade() -> None: + op.drop_table("exam_metadata") + op.drop_table("professor") + op.drop_table("course") diff --git a/src/alembic/versions/3f19883760ae_add_session_type_to_auth.py b/src/alembic/versions/3f19883760ae_add_session_type_to_auth.py new file mode 100644 index 0000000..b59ea36 --- /dev/null +++ b/src/alembic/versions/3f19883760ae_add_session_type_to_auth.py @@ -0,0 +1,25 @@ +"""add session_type to auth + +Revision ID: 3f19883760ae +Revises: 2a6ea95342dc +Create Date: 2025-01-03 00:16:50.579541 + +""" +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "3f19883760ae" +down_revision: str | None = "2a6ea95342dc" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.add_column("user_session", sa.Column("session_type", sa.String(48), nullable=False)) + +def downgrade() -> None: + op.drop_column("user_session", "session_type") diff --git a/src/auth/tables.py b/src/auth/tables.py index 041d6d1..03887d5 100644 --- a/src/auth/tables.py +++ b/src/auth/tables.py @@ -23,11 +23,8 @@ class UserSession(Base): String(SESSION_ID_LEN), nullable=False, unique=True ) # the space needed to store 256 bytes in base64 - # TODO: create a migration for this # whether a user is faculty, csss-member, student, or just "sfu" - session_type = Column( - String(SESSION_TYPE_LEN), nullable=False, - ) + session_type = Column(String(SESSION_TYPE_LEN), nullable=False) class SiteUser(Base): # user is a reserved word in postgres diff --git a/src/exambank/crud.py b/src/exambank/crud.py deleted file mode 100644 index 09cbac7..0000000 --- a/src/exambank/crud.py +++ /dev/null @@ -1,41 +0,0 @@ -from dataclasses import dataclass - -import database - - -@dataclass -class ExamMetadata: - exam_id: int - pdf_path: str - course_id: str - -async def create_exam(): - # for admins to run manually - # TODO: implement this later; for now just upload data manually - pass - -async def update_exam(): - # for admins to run manually - # TODO: implement this later; for now just upload data manually - pass - -async def all_exams( - db_session: database.DBSession, - course_starts_with: None | str, -): - # go through all exams (sorted by exam_id) & filter those which start with course_starts_with - # .like(f"%{course_starts_with}") - pass - -async def exam_metadata( - db_session: database.DBSession, - exam_id: int, -) -> ExamMetadata: - # TODO: implement this function - pass - -async def update_description(): - # TODO: implement this eventually, if we want students to contribute to - # the exam description - pass - diff --git a/src/exambank/tables.py b/src/exambank/tables.py index 8f43570..bea4a09 100644 --- a/src/exambank/tables.py +++ b/src/exambank/tables.py @@ -1,7 +1,7 @@ from datetime import datetime from types import ExamKind -from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, Text +from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, Text from sqlalchemy.orm import relationship from constants import COMPUTING_ID_LEN, SESSION_ID_LEN, SESSION_TYPE_LEN @@ -12,37 +12,44 @@ class ExamMetadata(Base): __tablename__ = "exam_metadata" - exam_id = Column(Integer, primary_key=True, autoincrement=True) + # exam_id is the number used to access the exam + exam_id = Column(Integer, primary_key=True) upload_date = Column(DateTime, nullable=False) - # with EXAM_BANK_DIR as the root - pdf_path = Column(String(128), nullable=False) - - # formatted f"{faculty} {course_number}" - course_id = Column(String(16), nullable=True) - primary_author = Column(Integer, nullable=False) # foreign key constraint - title = Column(String(64), nullable=True) # Something like "Midterm 2" or "Computational Geometry Final" - # TODO: if this gets big, maybe separate it to a different table + exam_pdf_size = Column(Integer, nullable=False) # in bytes + + author_id = Column(String(COMPUTING_ID_LEN), ForeignKey("professor.professor_id"), nullable=False) + # whether this is the confirmed author of the exam, or just suspected + author_confirmed = Column(Boolean, nullable=False) + # true if the professor has given permission for us to use their exam + author_permission = Column(Boolean, nullable=False) + + kind = Column(String(24), nullable=False) + course_id = Column(String(COMPUTING_ID_LEN), ForeignKey("course.professor_id"), nullable=True) + title = Column(String(96), nullable=True) # Something like "Midterm 2" or "Computational Geometry Final" description = Column(Text, nullable=True) # For a natural language description of the contents - kind = Column(String(16), nullable=False) - # TODO: on the resulting output table, include xxxx-xx-xx for unknown dates - year = Column(Integer, nullable=True) - month = Column(Integer, nullable=True) - day = Column(Integer, nullable=True) + # formatted as xxxx-xx-xx, include x for unknown dates + date_string = Column(String(10), nullable=False) + +# TODO: eventually hook the following tables in with the rest of the site & coursys api class Professor(Base): __tablename__ = "professor" - computing_id = Column( - String(COMPUTING_ID_LEN), - ForeignKey("user_session.computing_id"), - primary_key=True, - # Foreign key constriant w/ users table - nullable=True, - ) + professor_id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(String(128), nullable=False) + info_url = Column(String(128), nullable=False) # A url which provides more information about the professor + + # we may not know a professor's computing_id + computing_id = Column(String(COMPUTING_ID_LEN), ForeignKey("user_session.computing_id"), nullable=True) + +class Course(Base): + __tablename__ = "course" - name = Column(String(64), nullable=False) - info_url = Column(String(128), nullable=False) + course_id = Column(Integer, primary_key=True, autoincrement=True) -# TODO: eventually implement a table for courses & course info; hook it in with the rest of the site & coursys api + # formatted f"{faculty} {course_number}", ie. CMPT 300 + course_faculty = Column(String(12), nullable=False) + course_number = Column(String(12), nullable=False) + course_name = Column(String(96), nullable=False) diff --git a/src/exambank/urls.py b/src/exambank/urls.py index 18940eb..cfa9699 100644 --- a/src/exambank/urls.py +++ b/src/exambank/urls.py @@ -1,6 +1,8 @@ import os +import sqlalchemy from fastapi import APIRouter, HTTPException, JSONResponse, Request, Response +from tables import Course, ExamMetadata, Professor import database import exambank.crud @@ -9,6 +11,7 @@ from permission.types import ExamBankAccess from utils import path_in_dir +# all exams are stored here, and for the time being must be manually moved here EXAM_BANK_DIR = "/opt/csss-site/media/exam-bank" router = APIRouter( @@ -19,22 +22,42 @@ # TODO: update endpoints to use crud functions -> don't use crud actually; refactor to do that later @router.get( - "/list/exams" + "/metadata" ) -async def all_exams( +async def exam_metadata( request: Request, db_session: database.DBSession, - course_id_starts_with: str | None, ): + _, _ = await logged_in_or_raise(request, db_session) + await ExamBankAccess.has_permission_or_raise(request, errmsg="user must have exam bank access permission") + + """ courses = [f.name for f in os.scandir(f"{EXAM_BANK_DIR}") if f.is_dir()] if course_id_starts_with is not None: courses = [course for course in courses if course.startswith(course_id_starts_with)] exam_list = exambank.crud.all_exams(db_session, course_id_starts_with) return JSONResponse([exam.serializable_dict() for exam in exam_list]) + """ + + # TODO: test that the joins work correctly + exams = await db_session.scalar( + sqlalchemy + .select(ExamMetadata, Professor, Course) + .join(Professor) + .join(Course, isouter=True) # we want to have null values if the course is not known + .order_by(Course.course_number) + ) + + print(exams) + + # TODO: serialize exams somehow + return JSONResponse(exams) +# TODO: implement endpoint to fetch exams +""" @router.get( - "/get/{exam_id}" + "/exam/{exam_id}" ) async def get_exam( request: Request, @@ -62,3 +85,4 @@ async def get_exam( headers = { "Content-Disposition": f'inline; filename="{meta.course_id}_{exam_id}_{session_computing_id}.pdf"' } return Response(content=image_bytes, headers=headers, media_type="application/pdf") +"""