diff --git a/.github/workflows/alembic.yml b/.github/workflows/alembic.yml index dfa8ba14..7545afb0 100644 --- a/.github/workflows/alembic.yml +++ b/.github/workflows/alembic.yml @@ -38,7 +38,7 @@ jobs: python3.11 -m pip install --upgrade pip python3.11 -m venv venv source ./venv/bin/activate - pip install -r requirements.txt + pip install . # This will fail if there are divergent heads and alembic gets confused; # e.g., un-sanitarily merging main into a dev branch. diff --git a/.github/workflows/pytest_unit.yml b/.github/workflows/pytest_unit.yml index 6f75618d..8462f544 100644 --- a/.github/workflows/pytest_unit.yml +++ b/.github/workflows/pytest_unit.yml @@ -26,7 +26,7 @@ jobs: python -m pip install --upgrade pip python -m venv venv source ./venv/bin/activate - pip install -r requirements.txt + pip install ".[test]" - name: Run unit tests run: PYTHONPATH=src ./venv/bin/python -m pytest ./tests/unit -v diff --git a/.gitignore b/.gitignore index 1399d749..8bbedfde 100755 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,10 @@ google_key.json # Python - Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] +build/ +dist/ +wheels +*.egg-info/ *$py.class .venv diff --git a/README.md b/README.md index 2fc7db28..f8e36379 100755 --- a/README.md +++ b/README.md @@ -12,6 +12,35 @@ See [the csss-backend wiki](https://github.com/CSSS/csss-site-backend/wiki/1.-Lo If you're planning to read through the source code, please check out this project's [naming conventions](https://github.com/CSSS/csss-site-backend/wiki/Style-Guide#naming-conventions). +### Quickstart + +1. Install [Python 3.11](https://www.python.org/downloads/), [git](https://git-scm.com/install/), and (optionally) [Docker](https://www.docker.com/get-started/) + Note: This may fail if you're using Python 3.12+ +2. Clone this repository +3. Create and activate a virtual environment for this project. This has been tested with `pip` and `uv` +4. Install developer dependencies +```bash +# Install main dependencies +pip install . # or: uv pip install . + +# Install with dev dependencies +pip install ".[dev]" # or: uv pip install ".[dev]" + +# Install with test dependencies +pip install ".[test]" # or: uv pip install ".[test]" + +# Install with all dependencies +pip install ".[dev, test]" # or: uv pip install ".[dev, test]" +``` + +5. Follow the database setup instructions on the [wiki](https://github.com/CSSS/csss-site-backend/wiki/1.-Local-Setup#database-setup). The recommended way is to do it through Docker, but both should work. +6. You will need to set the following environment variables +```bash +export DB_PORT=5444 # The port your database is listening at +export LOCAL=true # Should be true if you're running this locally +``` + + ## Important Directories - `config/` configuration files for the server machine @@ -26,6 +55,7 @@ If you're planning to read through the source code, please check out this projec - `officers/` for officer contact information + photos - `test/` for html pages which interact with the backend's local api -## Linter +## Developer Tools We use `ruff 0.6.9` as our linter, which you can run with `ruff check --fix`. If you use a different version, it may be inconsistent with our CI checks. +We use `pyright/basedpyright` for typechecking. Language services have been left enabled and will be changed if it becomes an issue. diff --git a/pyproject.toml b/pyproject.toml index ff7761ad..09c93fce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,26 +1,58 @@ [project] name = "csss-site-backend" version = "0.1" -requires-python = ">= 3.11" # older versions untested, but we use new features often +requires-python = "~=3.11.0" # older versions untested, but we use new features often + +dependencies = [ + # major + "fastapi==0.115.6", + "gunicorn==21.2.0", + "uvicorn[standard]==0.27.1", + "sqlalchemy[asyncio]==2.0.27", + "asyncpg==0.29.0", + "alembic==1.13.1", + "google-api-python-client==2.143.0", + + # minor + "pyOpenSSL==24.0.0", # for generating cryptographically secure random numbers + "xmltodict==0.13.0", + "requests==2.31.0", +] + +[project.optional-dependencies] +dev = [ + "ruff==0.6.9", # linting and formatter +] + +test = [ + "pytest", # test framework + "pytest-asyncio", + "httpx", +] [project.urls] Homepage = "https://api.sfucsss.org/" +# Pytest: Test framework [tool.pytest.ini_options] pythonpath = ["src"] log_cli = true log_cli_level = "INFO" testpaths = [ "tests", - ] +] norecursedirs = "tests/wip" asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "function" +# Ruff: Formatter and linter [tool.ruff] line-length = 120 indent-width = 4 target-version = "py311" +exclude = [ + "src/alembic/*" +] [tool.ruff.format] quote-style = "double" @@ -31,6 +63,12 @@ line-ending = "lf" select = ["E", "F", "B", "I", "N", "UP", "A", "PTH", "W", "RUF", "C4", "PIE", "Q", "FLY"] # "ANN" ignore = ["E501", "F401", "N806"] +# [Based]Pyright: Type checker/LSP [tool.pyright] -executionEnvironments = [{ root = "src" }] +executionEnvironments = [ + { root = "src", pythonVersion = "3.11" }, + { root = "tests", extraPaths=["src"], pythonVersion = "3.11" } +] typeCheckingMode = "standard" +reportAny = "none" # Allow the use of `Any` type +reportExplicitAny = "none" # Allow the declaration of `Any` type diff --git a/requirements.txt b/requirements.txt deleted file mode 100755 index d29d6258..00000000 --- a/requirements.txt +++ /dev/null @@ -1,22 +0,0 @@ -# major -fastapi==0.115.6 -gunicorn==21.2.0 -uvicorn[standard]==0.27.1 -sqlalchemy[asyncio]==2.0.27 -asyncpg==0.29.0 -alembic==1.13.1 -google-api-python-client==2.143.0 - -# minor -pyOpenSSL==24.0.0 # for generating cryptographically secure random numbers -xmltodict==0.13.0 # for parsing responses from sfu it's auth api -requests==2.31.0 - -# dev -ruff==0.6.9 -# pre-commit - -# test -pytest -pytest-asyncio -httpx \ No newline at end of file diff --git a/src/__init__.py b/src/__init__.py index e69de29b..6abdd378 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -0,0 +1 @@ +from . import dependencies diff --git a/src/admin/email.py b/src/admin/email.py index 646512af..1ae507ed 100644 --- a/src/admin/email.py +++ b/src/admin/email.py @@ -6,6 +6,7 @@ GMAIL_ADDRESS = "csss-site@gmail.com" GMAIL_USERNAME = "" + # TODO: look into sending emails from an sfu maillist (this might be painful) def send_email( recipient_address: str, @@ -22,4 +23,3 @@ def send_email( mail.sendmail(GMAIL_ADDRESS, recipient_address, content) mail.quit() - diff --git a/src/auth/__init__.py b/src/auth/__init__.py index e69de29b..2bad3942 100644 --- a/src/auth/__init__.py +++ b/src/auth/__init__.py @@ -0,0 +1 @@ +from auth import crud diff --git a/src/auth/crud.py b/src/auth/crud.py index 0c447a27..9d896938 100644 --- a/src/auth/crud.py +++ b/src/auth/crud.py @@ -4,10 +4,11 @@ import sqlalchemy from sqlalchemy.ext.asyncio import AsyncSession -from auth.tables import SiteUser, UserSession +from auth.tables import SiteUserDB, UserSession _logger = logging.getLogger(__name__) + async def create_user_session(db_session: AsyncSession, session_id: str, computing_id: str): """ Updates the past user session if one exists, so no duplicate sessions can ever occur. @@ -15,14 +16,10 @@ async def create_user_session(db_session: AsyncSession, session_id: str, computi Also, adds the new user to the SiteUser table if it's their first time logging in. """ existing_user_session = await db_session.scalar( - sqlalchemy - .select(UserSession) - .where(UserSession.computing_id == computing_id) + sqlalchemy.select(UserSession).where(UserSession.computing_id == computing_id) ) existing_user = await db_session.scalar( - sqlalchemy - .select(SiteUser) - .where(SiteUser.computing_id == computing_id) + sqlalchemy.select(SiteUserDB).where(SiteUserDB.computing_id == computing_id) ) if existing_user is None: @@ -31,11 +28,9 @@ async def create_user_session(db_session: AsyncSession, session_id: str, computi _logger.warning(f"User session {session_id} exists for non-existent user {computing_id} ... !") # add new user to User table if it's their first time logging in - db_session.add(SiteUser( - computing_id=computing_id, - first_logged_in=datetime.now(), - last_logged_in=datetime.now() - )) + db_session.add( + SiteUserDB(computing_id=computing_id, first_logged_in=datetime.now(), last_logged_in=datetime.now()) + ) if existing_user_session is not None: existing_user_session.issue_time = datetime.now() @@ -44,11 +39,13 @@ async def create_user_session(db_session: AsyncSession, session_id: str, computi # update the last time the user logged in to now existing_user.last_logged_in = datetime.now() else: - db_session.add(UserSession( - session_id=session_id, - computing_id=computing_id, - issue_time=datetime.now(), - )) + db_session.add( + UserSession( + session_id=session_id, + computing_id=computing_id, + issue_time=datetime.now(), + ) + ) async def remove_user_session(db_session: AsyncSession, session_id: str): @@ -73,52 +70,32 @@ async def task_clean_expired_user_sessions(db_session: AsyncSession): # 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) -> SiteUser | None: - query = ( - sqlalchemy - .select(UserSession) - .where(UserSession.session_id == session_id) - ) +async def get_site_user(db_session: AsyncSession, session_id: str) -> SiteUserDB | None: + query = sqlalchemy.select(UserSession).where(UserSession.session_id == session_id) user_session = await db_session.scalar(query) if user_session is None: return None - query = ( - sqlalchemy - .select(SiteUser) - .where(SiteUser.computing_id == user_session.computing_id) - ) + query = sqlalchemy.select(SiteUserDB).where(SiteUserDB.computing_id == user_session.computing_id) return await db_session.scalar(query) + async def site_user_exists(db_session: AsyncSession, computing_id: str) -> bool: - user = await db_session.scalar( - sqlalchemy - .select(SiteUser) - .where(SiteUser.computing_id == computing_id) - ) + user = await db_session.scalar(sqlalchemy.select(SiteUserDB).where(SiteUserDB.computing_id == computing_id)) return user is not None # update the optional user info for a given site user (e.g., display name, profile picture, ...) -async def update_site_user( - db_session: AsyncSession, - session_id: str, - profile_picture_url: str -) -> bool: - query = ( - sqlalchemy - .select(UserSession) - .where(UserSession.session_id == session_id) - ) +async def update_site_user(db_session: AsyncSession, session_id: str, profile_picture_url: str) -> bool: + query = sqlalchemy.select(UserSession).where(UserSession.session_id == session_id) user_session = await db_session.scalar(query) if user_session is None: return False query = ( - sqlalchemy - .update(SiteUser) - .where(SiteUser.computing_id == user_session.computing_id) - .values(profile_picture_url = profile_picture_url) + sqlalchemy.update(SiteUserDB) + .where(SiteUserDB.computing_id == user_session.computing_id) + .values(profile_picture_url=profile_picture_url) ) await db_session.execute(query) diff --git a/src/auth/models.py b/src/auth/models.py index a428586d..15bfd35a 100644 --- a/src/auth/models.py +++ b/src/auth/models.py @@ -8,14 +8,17 @@ class LoginBodyParams(BaseModel): ticket: str = Field(description="Ticket return from SFU's CAS system") redirect_url: str | None = Field(None, description="Optional redirect URL") + class UpdateUserParams(BaseModel): profile_picture_url: str + class UserSessionModel(BaseModel): computing_id: str issue_time: datetime session_id: str + class SiteUserModel(BaseModel): computing_id: str first_logged_in: datetime diff --git a/src/auth/tables.py b/src/auth/tables.py index b2471cb9..58d93a05 100644 --- a/src/auth/tables.py +++ b/src/auth/tables.py @@ -26,7 +26,7 @@ class UserSession(Base): ) # the space needed to store 256 bytes in base64 -class SiteUser(Base): +class SiteUserDB(Base): # user is a reserved word in postgres # see: https://stackoverflow.com/questions/22256124/cannot-create-a-database-table-named-user-in-postgresql __tablename__ = "site_user" @@ -44,11 +44,7 @@ class SiteUser(Base): profile_picture_url: Mapped[str | None] = mapped_column(Text, nullable=True) def serialize(self) -> dict[str, str | int | bool | None]: - - res = { - "computing_id": self.computing_id, - "profile_picture_url": self.profile_picture_url - } + res = {"computing_id": self.computing_id, "profile_picture_url": self.profile_picture_url} if self.first_logged_in is not None: res["first_logged_in"] = self.first_logged_in.isoformat() if self.last_logged_in is not None: diff --git a/src/auth/types.py b/src/auth/types.py index 6587ca3d..5e478e3d 100644 --- a/src/auth/types.py +++ b/src/auth/types.py @@ -13,5 +13,5 @@ def serializable_dict(self): "computing_id": self.computing_id, "first_logged_in": self.first_logged_in, "last_logged_in": self.last_logged_in, - "profile_picture_url": self.profile_picture_url + "profile_picture_url": self.profile_picture_url, } diff --git a/src/auth/urls.py b/src/auth/urls.py index 290141ad..85167691 100644 --- a/src/auth/urls.py +++ b/src/auth/urls.py @@ -41,17 +41,14 @@ def generate_session_id_b64(num_bytes: int) -> str: response_description="Successfully validated with SFU's CAS", response_model=str, responses={ - 307: { "description": "Successful validation, with redirect" }, - 400: { "description": "Origin is missing.", "model": DetailModel }, - 401: { "description": "Failed to validate ticket with SFU's CAS", "model": DetailModel } + 307: {"description": "Successful validation, with redirect"}, + 400: {"description": "Origin is missing.", "model": DetailModel}, + 401: {"description": "Failed to validate ticket with SFU's CAS", "model": DetailModel}, }, operation_id="login", ) async def login_user( - request: Request, - db_session: database.DBSession, - background_tasks: BackgroundTasks, - body: LoginBodyParams + request: Request, db_session: database.DBSession, background_tasks: BackgroundTasks, body: LoginBodyParams ): # verify the ticket is valid service_url = body.service @@ -82,12 +79,7 @@ async def login_user( response = Response() response.set_cookie( - key="session_id", - value=session_id, - secure=IS_PROD, - httponly=True, - samesite=SAMESITE, - domain=DOMAIN + key="session_id", value=session_id, secure=IS_PROD, httponly=True, samesite=SAMESITE, domain=DOMAIN ) # this overwrites any past, possibly invalid, session_id return response @@ -96,7 +88,7 @@ async def login_user( "/logout", description="Logs out the current user by invalidating the session_id cookie", operation_id="logout", - response_model=MessageModel + response_model=MessageModel, ) async def logout_user( request: Request, @@ -121,9 +113,7 @@ async def logout_user( operation_id="get_user", description="Get info about the current user. Only accessible by that user", response_model=SiteUserModel, - responses={ - 401: { "description": "Not logged in.", "model": DetailModel } - }, + responses={401: {"description": "Not logged in.", "model": DetailModel}}, ) async def get_user( request: Request, @@ -149,9 +139,7 @@ async def get_user( operation_id="update_user", description="Update information for the currently logged in user. Only accessible by that user", response_model=str, - responses={ - 401: { "description": "Not logged in.", "model": DetailModel } - }, + responses={401: {"description": "Not logged in.", "model": DetailModel}}, ) async def update_user( body: UpdateUserParams, diff --git a/src/blog/crud.py b/src/blog/crud.py index 2b09ac35..55ccb960 100644 --- a/src/blog/crud.py +++ b/src/blog/crud.py @@ -1,67 +1,70 @@ -import logging -from datetime import date, datetime - -import sqlalchemy -from sqlalchemy import func - -import database -from blog.models import BlogPosts - - -async def create_new_entry( - db_session: database.DBSession, - title:str, computing_id:str, post_tags: str, html_content:str, - date_created:date, last_edited: date -): - """ To create a new blog entry """ - - #TODO - -async def fetch_by_title( - db_session: database.DBSession, - title: str -) -> tuple[str, str, datetime, list[str] | None] | None: - """ Returns the blog entry with the matching title """ - # returns title, html, date, and list of tags - - query = sqlalchemy.select(BlogPosts) - # query will only return an entry if the title is an exact - # match ( as title is the unique key) - # should be only one result to return - query = query.where(BlogPosts.title == title) - - # should return the one entry with an unique title - post = await db_session.scalar(query) - - # picking out the specific fields we want returned - return post.html_content, post.last_edited, post.post_tags - - -async def fetch_by_date_and_tag( - db_session: database.DBSession, - last_edited: date, - tags:str -) -> (str, str | None) | None: - """" Returns blog entries sorted by date of last edit and containing matching tags """ - # returns title and html - - query = sqlalchemy.select(BlogPosts) - # checks for matching tags first - # then sort by date of last edit - query = query.where(BlogPosts.post_tags in tags).where(BlogPosts.last_edited).order_by(BlogPosts.last_edited.desc()) - # .all() should return a list of all the posts - post = await db_session(query).all() - - # now what - return post - - -async def update_entry( - db_session: database.DBSession, - title:str, - html_content:str, - last_edited: func.now(), -): - """ To update html contents of an existing entry """ - - #TODO +import logging +from datetime import date, datetime + +import sqlalchemy +from sqlalchemy import func + +import database +from blog.models import BlogPosts + + +async def create_new_entry( + db_session: database.DBSession, + title: str, + computing_id: str, + post_tags: str, + html_content: str, + date_created: date, + last_edited: date, +): + """To create a new blog entry""" + + # TODO + + +async def fetch_by_title( + db_session: database.DBSession, title: str +) -> tuple[str, str, datetime, list[str] | None] | None: + """Returns the blog entry with the matching title""" + # returns title, html, date, and list of tags + + query = sqlalchemy.select(BlogPosts) + # query will only return an entry if the title is an exact + # match ( as title is the unique key) + # should be only one result to return + query = query.where(BlogPosts.title == title) + + # should return the one entry with an unique title + post = await db_session.scalar(query) + + # picking out the specific fields we want returned + return post.html_content, post.last_edited, post.post_tags + + +async def fetch_by_date_and_tag(db_session: database.DBSession, last_edited: date, tags: str) -> ( + str, + str | None, +) | None: + """ " Returns blog entries sorted by date of last edit and containing matching tags""" + # returns title and html + + query = sqlalchemy.select(BlogPosts) + # checks for matching tags first + # then sort by date of last edit + query = query.where(BlogPosts.post_tags in tags).where(BlogPosts.last_edited).order_by(BlogPosts.last_edited.desc()) + # .all() should return a list of all the posts + post = await db_session(query).all() + + # now what + return post + + +async def update_entry( + db_session: database.DBSession, + title: str, + html_content: str, + last_edited: func.now(), +): + """To update html contents of an existing entry""" + + # TODO diff --git a/src/blog/tables.py b/src/blog/tables.py index 67505fc7..ed585526 100644 --- a/src/blog/tables.py +++ b/src/blog/tables.py @@ -1,33 +1,33 @@ -from sqlalchemy import Column, DateTime, ForeignKey, String, Text - -from constants import COMPUTING_ID_LEN -from database import Base -from officers import tables - - -# blog table -class BlogPosts(Base): - # table name - __tablename__ = "blog_posts" - - # title of post ( meant to be an unique key but for simplicity, - # using already defined primay_key ) - title = Column(String(128), primary_key=True, nullable=False) - - # computing id - computing_id = Column( - String(COMPUTING_ID_LEN), - ForeignKey("officer_info.computing_id"), - nullable=False, - ) - - # dates of creation and last edit - date_created = Column(DateTime, nullable=False) - last_edited = Column(DateTime, nullable=False) - - # storing the html content - html_content = Column(Text, nullable=False) - - # tags for the respective post - # TODO: consider implementing limits for tag size and count - post_tags = Column(String(128)) +from sqlalchemy import Column, DateTime, ForeignKey, String, Text + +from constants import COMPUTING_ID_LEN +from database import Base +from officers import tables + + +# blog table +class BlogPosts(Base): + # table name + __tablename__ = "blog_posts" + + # title of post ( meant to be an unique key but for simplicity, + # using already defined primay_key ) + title = Column(String(128), primary_key=True, nullable=False) + + # computing id + computing_id = Column( + String(COMPUTING_ID_LEN), + ForeignKey("officer_info.computing_id"), + nullable=False, + ) + + # dates of creation and last edit + date_created = Column(DateTime, nullable=False) + last_edited = Column(DateTime, nullable=False) + + # storing the html content + html_content = Column(Text, nullable=False) + + # tags for the respective post + # TODO: consider implementing limits for tag size and count + post_tags = Column(String(128)) diff --git a/src/blog/url.py b/src/blog/url.py index 6ad995fc..fea89784 100644 --- a/src/blog/url.py +++ b/src/blog/url.py @@ -1,16 +1,16 @@ -import logging - -from fastapi import APIRouter, Request -from fastapi.responses import JSONResponse - -import auth -import blog.crud -import database -from permission.types import OfficerPrivateInfo - -_logger = logging.getLogger(__name__) - -router = APIRouter( - prefix="/blog", - tags=["blog"], -) +import logging + +from fastapi import APIRouter, Request +from fastapi.responses import JSONResponse + +import auth +import blog.crud +import database +from permission.types import OfficerPrivateInfo + +_logger = logging.getLogger(__name__) + +router = APIRouter( + prefix="/blog", + tags=["blog"], +) diff --git a/src/constants.py b/src/constants.py index 3b6a9c73..fe017a76 100644 --- a/src/constants.py +++ b/src/constants.py @@ -28,5 +28,5 @@ GITHUB_USERNAME_LEN = 39 # COOKIE -SAMESITE="none" if IS_PROD else "lax" -DOMAIN=".sfucsss.org" if IS_PROD else None +SAMESITE = "none" if IS_PROD else "lax" +DOMAIN = ".sfucsss.org" if IS_PROD else None diff --git a/src/cron/daily.py b/src/cron/daily.py index 37e949b0..76b7564a 100644 --- a/src/cron/daily.py +++ b/src/cron/daily.py @@ -7,10 +7,11 @@ import google_api import utils from database import get_db_session -from officers.crud import all_officers, get_user_by_username +from officers.crud import get_all_officers, get_user_by_username _logger = logging.getLogger(__name__) + async def update_google_permissions(db_session): # TODO: implement this function # google_permissions = google_api.all_permissions() @@ -18,7 +19,7 @@ async def update_google_permissions(db_session): # TODO: for performance, only include officers with recent end-date (1 yr) # but measure performance first - for term in await all_officers(db_session): + for term in await get_all_officers(db_session): if utils.is_active(term): # TODO: if google drive permission is not active, update them pass @@ -28,10 +29,11 @@ async def update_google_permissions(db_session): _logger.info("updated google permissions") + async def update_github_permissions(db_session): github_permissions, team_id_map = github.all_permissions() - for term in await all_officers(db_session): + for term in await get_all_officers(db_session): new_teams = ( # move all active officers to their respective teams github.officer_teams(term.position) @@ -46,14 +48,11 @@ async def update_github_permissions(db_session): [team_id_map[team] for team in new_teams], ) else: - github.set_user_teams( - term.username, - github_permissions[term.username].teams, - new_teams - ) + github.set_user_teams(term.username, github_permissions[term.username].teams, new_teams) _logger.info("updated github permissions") + async def update_permissions(): db_session = get_db_session() @@ -64,6 +63,6 @@ async def update_permissions(): _logger.info("all permissions updated") + if __name__ == "__main__": asyncio.run(update_permissions()) - diff --git a/src/data/semesters.py b/src/data/semesters.py index a911f962..579ebff8 100644 --- a/src/data/semesters.py +++ b/src/data/semesters.py @@ -2,6 +2,10 @@ from enum import Enum from typing import assert_never +JANUARY = 1 +MAY = 5 +SEPTEMBER = 9 + class Semester(Enum): """semester numbers are assigned by their order in the year""" @@ -18,7 +22,7 @@ def __str__(self): elif self.value == 2: return "fall" else: - assert_never() + assert_never(self.value) def step_semesters(semester_start_date: date, num_semesters: int) -> date: @@ -32,30 +36,32 @@ def step_semesters(semester_start_date: date, num_semesters: int) -> date: def current_semester_start(the_date: date) -> date: - if the_date.month >= 9: - return date(year=the_date.year, month=9, day=1) - elif the_date.month >= 5: - return date(year=the_date.year, month=5, day=1) - elif the_date.month >= 1: - return date(year=the_date.year, month=1, day=1) + if the_date.month >= SEPTEMBER: + return date(year=the_date.year, month=SEPTEMBER, day=1) + elif the_date.month >= MAY: + return date(year=the_date.year, month=MAY, day=1) + elif the_date.month >= JANUARY: + return date(year=the_date.year, month=JANUARY, day=1) + else: + raise AssertionError("unreachable") def current_semester(the_date: date) -> Semester: - if the_date.month >= 9: + if the_date.month >= SEPTEMBER: return Semester.Fall - elif the_date.month >= 5: + elif the_date.month >= MAY: return Semester.Summer - elif the_date.month >= 1: + elif the_date.month >= JANUARY: return Semester.Spring else: - assert_never() + raise AssertionError("unreachable") def get_semester_start(year: int, semester: Semester): match semester: case Semester.Fall: - return date(year, month=9, day=1) + return date(year, month=SEPTEMBER, day=1) case Semester.Summer: - return date(year, month=5, day=1) + return date(year, month=MAY, day=1) case Semester.Spring: - return date(year, month=1, day=1) + return date(year, month=JANUARY, day=1) diff --git a/src/database.py b/src/database.py index 470c7ca0..366916ce 100644 --- a/src/database.py +++ b/src/database.py @@ -15,16 +15,18 @@ from sqlalchemy.orm import DeclarativeBase convention = { - "ix": "ix_%(column_0_label)s", # index - "uq": "uq_%(table_name)s_%(column_0_name)s", # unique - "ck": "ck_%(table_name)s_%(constraint_name)s", # check - "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s", # foreign key - "pk": "pk_%(table_name)s", # primary key + "ix": "ix_%(column_0_label)s", # index + "uq": "uq_%(table_name)s_%(column_0_name)s", # unique + "ck": "ck_%(table_name)s_%(constraint_name)s", # check + "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s", # foreign key + "pk": "pk_%(table_name)s", # primary key } + class Base(DeclarativeBase): metadata = MetaData(naming_convention=convention) + # from: https://medium.com/@tclaitken/setting-up-a-fastapi-app-with-async-sqlalchemy-2-0-pydantic-v2-e6c540be4308 class DatabaseSessionManager: def __init__(self, db_url: str, engine_kwargs: dict[str, Any], check_db=True): @@ -44,7 +46,9 @@ async def test_connection(sqlalchemy_db_url: str): conn = await asyncpg.connect(asyncpg_db_url) await conn.close() except Exception as e: - raise Exception(f"Could not connect to {sqlalchemy_db_url}. Postgres database might not exist. Got: {e}") from e + raise Exception( + f"Could not connect to {sqlalchemy_db_url}. Postgres database might not exist. Got: {e}" + ) from e # TODO: setup logging print(f"successful connection test to {sqlalchemy_db_url}") @@ -102,9 +106,10 @@ def setup_database(): # TODO: where is sys.stdout piped to? I want all these to go to a specific logs folder sessionmanager = DatabaseSessionManager( SQLALCHEMY_TEST_DATABASE_URL if os.environ.get("LOCAL") else SQLALCHEMY_DATABASE_URL, - { "echo": False }, + {"echo": True}, ) + @contextlib.asynccontextmanager async def lifespan(app: FastAPI): """ diff --git a/src/dependencies.py b/src/dependencies.py new file mode 100644 index 00000000..6ebc142f --- /dev/null +++ b/src/dependencies.py @@ -0,0 +1,55 @@ +from typing import Annotated + +from fastapi import Cookie, Depends, HTTPException, status + +import auth +import database +from utils.permissions import is_user_election_officer, is_user_website_admin + + +async def user(db_session: database.DBSession, session_id: Annotated[str | None, Cookie()] = None) -> str | None: + if session_id is None: + return None + + session_computing_id = await auth.crud.get_computing_id(db_session, session_id) + + return session_computing_id + + +SessionUser = Annotated[str, Depends(user)] + + +async def logged_in_user(db_session: database.DBSession, session_id: Annotated[str | None, Cookie()] = None) -> str: + if session_id is None: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="no session id") + + session_computing_id = await auth.crud.get_computing_id(db_session, session_id) + if session_computing_id is None: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="no computing id") + + return session_computing_id + + +LoggedInUser = Annotated[str, Depends(logged_in_user)] + + +async def perm_election(db_session: database.DBSession, computing_id: LoggedInUser) -> str: + if not await is_user_website_admin(computing_id, db_session) or not await is_user_election_officer( + computing_id, db_session + ): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="must be an election admin") + + return computing_id + + +ElectionAdmin = Annotated[str, Depends(perm_election)] + + +async def perm_admin(db_session: database.DBSession, computing_id: LoggedInUser): + if not await is_user_website_admin(computing_id, db_session): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="must be an admin") + + return computing_id + + +SiteAdmin = Annotated[str, Depends(perm_admin)] diff --git a/src/discord/discord.py b/src/discord/discord.py index 4393b453..7afe9715 100644 --- a/src/discord/discord.py +++ b/src/discord/discord.py @@ -17,6 +17,7 @@ # this is the "Application ID" TOKEN = os.environ.get("DISCORD_TOKEN") + @dataclass class User: id: str @@ -30,11 +31,13 @@ class User: global_name: str | None = None avatar: str | None = None + @dataclass class GuildMember: user: User roles: list[str] | None = None + @dataclass class Channel: id: str @@ -43,21 +46,20 @@ class Channel: name: str permission_overwrites: list[str] | None = None + def is_active() -> bool: # if there is no discord token, then consider the module inactive; calling functions may fail without warning! return os.environ.get("DISCORD_TOKEN") is not None -async def _discord_request( - url: str, - token: str -) -> Response: + +async def _discord_request(url: str, token: str) -> Response: result = requests.get( url, headers={ "Authorization": f"Bot {token}", "Content-Type": "application/json", - "User-Agent" : "DiscordBot (https://github.com/CSSS/csss-site-backend, 1.0)" - } + "User-Agent": "DiscordBot (https://github.com/CSSS/csss-site-backend, 1.0)", + }, ) rate_limit_reset = float(result.headers["x-ratelimit-reset-after"]) rate_limit_remaining_requests = int(result.headers["x-ratelimit-remaining"]) @@ -71,10 +73,11 @@ async def _discord_request( return result + async def get_channel_members( cid: str, # TODO: hardcode guild_id (remove it as argument) if we ever refactor this module - gid: str = ACTIVE_GUILD_ID + gid: str = ACTIVE_GUILD_ID, ) -> list[GuildMember]: """ Returns empty list if invalid channel id is provided. @@ -88,7 +91,8 @@ async def get_channel_members( "type": x["type"], "allow": x["allow"], "deny": x["deny"], - } for x in channel[0]["permission_overwrites"] + } + for x in channel[0]["permission_overwrites"] } # note that there can exist only one @everyone override, break if found @@ -145,10 +149,8 @@ async def get_channel_members( return users_with_access -async def get_channel( - cid: str, - gid: str = ACTIVE_GUILD_ID -) -> Channel | None: + +async def get_channel(cid: str, gid: str = ACTIVE_GUILD_ID) -> Channel | None: url = f"https://discord.com/api/v10/guilds/{gid}/channels" result = await _discord_request(url, TOKEN) @@ -157,11 +159,12 @@ async def get_channel( if channel is None: return None else: - return Channel(channel["id"], channel["type"], channel["guild_id"], channel["name"], channel["permission_overwrites"]) + return Channel( + channel["id"], channel["type"], channel["guild_id"], channel["name"], channel["permission_overwrites"] + ) + -async def get_all_channels( - gid: str = ACTIVE_GUILD_ID -) -> list[str]: +async def get_all_channels(gid: str = ACTIVE_GUILD_ID) -> list[str]: url = f"https://discord.com/api/v10/guilds/{gid}/channels" result = await _discord_request(url, TOKEN) @@ -171,36 +174,29 @@ async def get_all_channels( return channel_names -async def get_role_name_by_id( - rid: str, - gid: str = ACTIVE_GUILD_ID -) -> str: + +async def get_role_name_by_id(rid: str, gid: str = ACTIVE_GUILD_ID) -> str: roles = await get_all_roles(gid) return roles[rid][0] -async def get_role_by_id( - rid: str, - gid: str = ACTIVE_GUILD_ID -) -> dict | None: + +async def get_role_by_id(rid: str, gid: str = ACTIVE_GUILD_ID) -> dict | None: url = f"https://discord.com/api/v10/guilds/{gid}/roles" result = await _discord_request(url, TOKEN) result_json = result.json() return next((role for role in result_json if role["id"] == rid), None) -async def get_user_roles( - uid: str, - gid: str = ACTIVE_GUILD_ID -) -> list[str]: + +async def get_user_roles(uid: str, gid: str = ACTIVE_GUILD_ID) -> list[str]: url = f"https://discord.com/api/v10/guilds/{gid}/members/{uid}" result = await _discord_request(url, TOKEN) result_json = result.json() return result_json["roles"] -async def get_all_roles( - gid: str = ACTIVE_GUILD_ID -) -> dict[str, list[str]]: + +async def get_all_roles(gid: str = ACTIVE_GUILD_ID) -> dict[str, list[str]]: """ Grabs all roles in a given guild. """ @@ -211,10 +207,8 @@ async def get_all_roles( roles = [([role["id"], [role["name"], role["permissions"]]]) for role in result_json] return dict(roles) -async def get_guild_members_with_role( - rid: str, - gid: str = ACTIVE_GUILD_ID -) -> list[GuildMember]: + +async def get_guild_members_with_role(rid: str, gid: str = ACTIVE_GUILD_ID) -> list[GuildMember]: # base case url = f"https://discord.com/api/v10/guilds/{gid}/members?limit=1000" result = await _discord_request(url, TOKEN) @@ -222,14 +216,18 @@ async def get_guild_members_with_role( result_json = result.json() matched = [ - GuildMember(User( - user["user"]["id"], - user["user"]["username"], - user["user"]["discriminator"], - user["user"]["global_name"], - user["user"]["avatar"] - ), user["roles"]) - for user in result_json if rid in user["roles"] + GuildMember( + User( + user["user"]["id"], + user["user"]["username"], + user["user"]["discriminator"], + user["user"]["global_name"], + user["user"]["avatar"], + ), + user["roles"], + ) + for user in result_json + if rid in user["roles"] ] last_uid = matched[-1].user.id @@ -243,15 +241,26 @@ async def get_guild_members_with_role( if len(result_json) == 0: return matched - res = [GuildMember(User(user["user"]["id"], user["user"]["username"], user["user"]["discriminator"], user["user"]["global_name"], user["user"]["avatar"]), user["roles"]) - for user in result_json if rid in user["roles"]] + res = [ + GuildMember( + User( + user["user"]["id"], + user["user"]["username"], + user["user"]["discriminator"], + user["user"]["global_name"], + user["user"]["avatar"], + ), + user["roles"], + ) + for user in result_json + if rid in user["roles"] + ] matched += res last_uid = res[-1].user.id -async def get_guild_members( - gid: str = ACTIVE_GUILD_ID -) -> list[GuildMember]: + +async def get_guild_members(gid: str = ACTIVE_GUILD_ID) -> list[GuildMember]: # base case url = f"https://discord.com/api/v10/guilds/{gid}/members?limit=1000" result = await _discord_request(url, TOKEN) @@ -268,8 +277,9 @@ async def get_guild_members( user["user"]["global_name"], user["user"]["avatar"], ), - user["roles"] - ) for user in result.json() + user["roles"], + ) + for user in result.json() ] last_uid = users[-1].user.id @@ -290,76 +300,56 @@ async def get_guild_members( user["user"]["global_name"], user["user"]["avatar"], ), - user["roles"] - ) for user in result_json + user["roles"], + ) + for user in result_json ] users += res last_uid = res[-1].user.id -async def get_categories( - gid: str = ACTIVE_GUILD_ID -) -> list[str]: + +async def get_categories(gid: str = ACTIVE_GUILD_ID) -> list[str]: url = f"https://discord.com/api/v10/guilds/{gid}/channels" result = await _discord_request(url, TOKEN) result_json = result.json() return [category["name"] for category in result_json if category["type"] == DISCORD_CATEGORY_ID] -async def get_channels_by_category_name( - category_name: str, - gid: str = ACTIVE_GUILD_ID -) -> list[Channel]: + +async def get_channels_by_category_name(category_name: str, gid: str = ACTIVE_GUILD_ID) -> list[Channel]: url = f"https://discord.com/api/v10/guilds/{gid}/channels" result = await _discord_request(url, TOKEN) result_json = result.json() # TODO: edge case if there exist duplicate category names, see get_channels_by_category_id() category_id = next( - category["id"] for category in result_json - if category["type"] == DISCORD_CATEGORY_ID - and category["name"] == category_name + category["id"] + for category in result_json + if category["type"] == DISCORD_CATEGORY_ID and category["name"] == category_name ) channels = [ - Channel( - channel["id"], - channel["type"], - channel["guild_id"], - channel["name"], - channel["permission_overwrites"] - ) + Channel(channel["id"], channel["type"], channel["guild_id"], channel["name"], channel["permission_overwrites"]) for channel in result_json - if channel["type"] != DISCORD_CATEGORY_ID - and channel["parent_id"] == category_id + if channel["type"] != DISCORD_CATEGORY_ID and channel["parent_id"] == category_id ] return channels -async def get_channels_by_category_id( - cid: str, - gid: str = ACTIVE_GUILD_ID -) -> list[Channel]: + +async def get_channels_by_category_id(cid: str, gid: str = ACTIVE_GUILD_ID) -> list[Channel]: url = f"https://discord.com/api/v10/guilds/{gid}/channels" result = await _discord_request(url, TOKEN) result_json = result.json() channels = [ - Channel( - channel["id"], - channel["type"], - channel["guild_id"], - channel["name"], - channel["permission_overwrites"] - ) for channel in result_json - if channel["type"] != DISCORD_CATEGORY_ID - and channel["parent_id"] == cid + Channel(channel["id"], channel["type"], channel["guild_id"], channel["name"], channel["permission_overwrites"]) + for channel in result_json + if channel["type"] != DISCORD_CATEGORY_ID and channel["parent_id"] == cid ] return channels -async def search_user( - starts_with: str, - limit: int = 1, - gid: str = ACTIVE_GUILD_ID -) -> list[User]: + +async def search_user(starts_with: str, limit: int = 1, gid: str = ACTIVE_GUILD_ID) -> list[User]: """ Returns a list of User objects "whose username or nickname starts with a provided string" """ @@ -375,14 +365,13 @@ async def search_user( entry["user"]["username"], entry["user"]["discriminator"], entry["user"]["global_name"], - entry["user"]["avatar"] - ) for entry in result.json() + entry["user"]["avatar"], + ) + for entry in result.json() ] -async def search_username( - username_starts_with: str, - gid: str = ACTIVE_GUILD_ID -) -> list[User]: + +async def search_username(username_starts_with: str, gid: str = ACTIVE_GUILD_ID) -> list[User]: """ Returns a list of User objects whose username starts with a provided string. @@ -390,8 +379,4 @@ async def search_username( """ # if there are more than 100 users with the same nickname as the "username_starts_with" string, this may fail user_list = await search_user(username_starts_with, 99, gid) - return [ - user for user in user_list - if user.username.startswith(username_starts_with) - and user.discriminator == "0" - ] + return [user for user in user_list if user.username.startswith(username_starts_with) and user.discriminator == "0"] diff --git a/src/elections/crud.py b/src/elections/crud.py index 3d99ce9e..2aab3bf5 100644 --- a/src/elections/crud.py +++ b/src/elections/crud.py @@ -7,18 +7,13 @@ async def get_all_elections(db_session: AsyncSession) -> Sequence[Election]: - election_list = (await db_session.scalars( - sqlalchemy - .select(Election) - )).all() + election_list = (await db_session.scalars(sqlalchemy.select(Election))).all() return election_list + async def get_election(db_session: AsyncSession, election_slug: str) -> Election | None: - return await db_session.scalar( - sqlalchemy - .select(Election) - .where(Election.slug == election_slug) - ) + return await db_session.scalar(sqlalchemy.select(Election).where(Election.slug == election_slug)) + async def create_election(db_session: AsyncSession, election: Election): """ @@ -27,23 +22,18 @@ async def create_election(db_session: AsyncSession, election: Election): """ db_session.add(election) + async def update_election(db_session: AsyncSession, new_election: Election): """ Attempting to change slug will fail. Instead, you must create a new election. """ await db_session.execute( - sqlalchemy - .update(Election) - .where(Election.slug == new_election.slug) - .values(new_election.to_update_dict()) + sqlalchemy.update(Election).where(Election.slug == new_election.slug).values(new_election.to_update_dict()) ) + async def delete_election(db_session: AsyncSession, slug: str) -> None: """ Deletes a given election by its slug. Does not validate if an election exists """ - await db_session.execute( - sqlalchemy - .delete(Election) - .where(Election.slug == slug) - ) + await db_session.execute(sqlalchemy.delete(Election).where(Election.slug == slug)) diff --git a/src/elections/models.py b/src/elections/models.py index 429924b6..24adec13 100644 --- a/src/elections/models.py +++ b/src/elections/models.py @@ -11,12 +11,14 @@ class ElectionTypeEnum(StrEnum): BY_ELECTION = "by_election" COUNCIL_REP = "council_rep_election" + class ElectionStatusEnum(StrEnum): BEFORE_NOMINATIONS = "before_nominations" NOMINATIONS = "nominations" VOTING = "voting" AFTER_VOTING = "after_voting" + class ElectionResponse(BaseModel): slug: str name: str @@ -27,9 +29,11 @@ class ElectionResponse(BaseModel): available_positions: list[OfficerPositionEnum] status: ElectionStatusEnum + # Private fields survey_link: str | None = Field(None, description="Only available to admins") candidates: list[RegistrationModel] | None = Field(None, description="Only available to admins") + class ElectionParams(BaseModel): name: str type: ElectionTypeEnum @@ -39,6 +43,7 @@ class ElectionParams(BaseModel): available_positions: list[OfficerPositionEnum] | None = None survey_link: str | None = None + class ElectionUpdateParams(BaseModel): type: ElectionTypeEnum | None = None datetime_start_nominations: str | None = None @@ -46,4 +51,3 @@ class ElectionUpdateParams(BaseModel): datetime_end_voting: str | None = None available_positions: list[OfficerPositionEnum] | None = None survey_link: str | None = None - diff --git a/src/elections/tables.py b/src/elections/tables.py index 270631a2..7ef1b6fd 100644 --- a/src/elections/tables.py +++ b/src/elections/tables.py @@ -18,6 +18,7 @@ MAX_ELECTION_NAME = 64 MAX_ELECTION_SLUG = 64 + class Election(Base): __tablename__ = "election" @@ -33,7 +34,10 @@ class Election(Base): # By giving it the type `StringList`, the database entry will automatically be marshalled to the correct form # DB -> Python: str -> list[str] # Python -> DB: list[str] -> str - available_positions: Mapped[list[OfficerPositionEnum]] = mapped_column(StringList(), nullable=False,) + available_positions: Mapped[list[OfficerPositionEnum]] = mapped_column( + StringList(), + nullable=False, + ) survey_link: Mapped[str | None] = mapped_column(String(300)) def private_details(self, at_time: datetime) -> dict: @@ -42,11 +46,9 @@ def private_details(self, at_time: datetime) -> dict: "slug": self.slug, "name": self.name, "type": self.type, - "datetime_start_nominations": self.datetime_start_nominations.isoformat(), "datetime_start_voting": self.datetime_start_voting.isoformat(), "datetime_end_voting": self.datetime_end_voting.isoformat(), - "status": self.status(at_time), "available_positions": self.available_positions, "survey_link": self.survey_link, @@ -58,11 +60,9 @@ def public_details(self, at_time: datetime) -> dict: "slug": self.slug, "name": self.name, "type": self.type, - "datetime_start_nominations": self.datetime_start_nominations.isoformat(), "datetime_start_voting": self.datetime_start_voting.isoformat(), "datetime_end_voting": self.datetime_end_voting.isoformat(), - "status": self.status(at_time), "available_positions": self.available_positions, } @@ -73,11 +73,9 @@ def public_metadata(self, at_time: datetime) -> dict: "slug": self.slug, "name": self.name, "type": self.type, - "datetime_start_nominations": self.datetime_start_nominations.isoformat(), "datetime_start_voting": self.datetime_start_voting.isoformat(), "datetime_end_voting": self.datetime_end_voting.isoformat(), - "status": self.status(at_time), } @@ -86,19 +84,16 @@ def to_update_dict(self) -> dict: "slug": self.slug, "name": self.name, "type": self.type, - "datetime_start_nominations": self.datetime_start_nominations, "datetime_start_voting": self.datetime_start_voting, "datetime_end_voting": self.datetime_end_voting, - "available_positions": self.available_positions, "survey_link": self.survey_link, } def update_from_params(self, params: ElectionUpdateParams): update_data = params.model_dump( - exclude_unset=True, - exclude={"datetime_start_nominations", "datetime_start_voting", "datetime_end_voting"} + exclude_unset=True, exclude={"datetime_start_nominations", "datetime_start_voting", "datetime_end_voting"} ) for k, v in update_data.items(): setattr(self, k, v) @@ -118,4 +113,3 @@ def status(self, at_time: datetime) -> str: return ElectionStatusEnum.VOTING else: return ElectionStatusEnum.AFTER_VOTING - diff --git a/src/elections/urls.py b/src/elections/urls.py index 3dbbf39f..47ae7846 100644 --- a/src/elections/urls.py +++ b/src/elections/urls.py @@ -1,6 +1,6 @@ import datetime -from fastapi import APIRouter, HTTPException, Request, status +from fastapi import APIRouter, Depends, HTTPException, status from fastapi.responses import JSONResponse import database @@ -8,6 +8,7 @@ import elections.tables import nominees.crud import registrations.crud +from dependencies import LoggedInUser, SessionUser, perm_election from elections.models import ( ElectionParams, ElectionResponse, @@ -16,29 +17,15 @@ ) from elections.tables import Election from officers.constants import COUNCIL_REP_ELECTION_POSITIONS, GENERAL_ELECTION_POSITIONS, OfficerPositionEnum -from permission.types import ElectionOfficer, WebsiteAdmin +from utils.permissions import is_user_election_officer from utils.shared_models import DetailModel, SuccessResponse -from utils.urls import get_current_user, slugify +from utils.urls import slugify router = APIRouter( prefix="/election", tags=["election"], ) -async def get_election_permissions( - request: Request, - db_session: database.DBSession, -) -> tuple[bool, str | None, str | None]: - session_id, computing_id = await get_current_user(request, db_session) - if not session_id or not computing_id: - return False, None, None - - # where valid means election officer or website admin - has_permission = await ElectionOfficer.has_permission(db_session, computing_id) - if not has_permission: - has_permission = await WebsiteAdmin.has_permission(db_session, computing_id) - - return has_permission, session_id, computing_id def _default_election_positions(election_type: ElectionTypeEnum) -> list[OfficerPositionEnum]: if election_type == ElectionTypeEnum.GENERAL: @@ -56,7 +43,7 @@ def _raise_if_bad_election_data( datetime_start_nominations: datetime.datetime, datetime_start_voting: datetime.datetime, datetime_end_voting: datetime.datetime, - available_positions: list[OfficerPositionEnum] + available_positions: list[OfficerPositionEnum], ): if election_type not in ElectionTypeEnum: raise HTTPException( @@ -83,78 +70,57 @@ def _raise_if_bad_election_data( detail=f"election slug '{slug}' is too long", ) + @router.get( "", description="Returns a list of all election & their status", response_model=list[ElectionResponse], - responses={ - 404: { "description": "No election found", "model": DetailModel } - }, - operation_id="get_all_elections" + responses={status.HTTP_404_NOT_FOUND: {"description": "No election found", "model": DetailModel}}, + operation_id="get_all_elections", ) async def list_elections( - request: Request, + computing_id: LoggedInUser, db_session: database.DBSession, ): - is_admin, _, _ = await get_election_permissions(request, db_session) election_list = await elections.crud.get_all_elections(db_session) if election_list is None or len(election_list) == 0: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="no election found" - ) + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="no election found") current_time = datetime.datetime.now() - if is_admin: - election_metadata_list = [ - election.private_details(current_time) - for election in election_list - ] + if is_user_election_officer(computing_id, db_session): + election_metadata_list = [election.private_details(current_time) for election in election_list] else: - election_metadata_list = [ - election.public_details(current_time) - for election in election_list - ] + election_metadata_list = [election.public_details(current_time) for election in election_list] return JSONResponse(election_metadata_list) + @router.get( - "/{election_name:str}", + "/{election_name}", description=""" Retrieves the election data for an election by name. Returns private details when the time is allowed. If user is an admin or election officer, returns computing ids for each candidate as well. """, response_model=ElectionResponse, - responses={ - 404: { "description": "Election of that name doesn't exist", "model": DetailModel } - }, - operation_id="get_election_by_name" + responses={404: {"description": "Election of that name doesn't exist", "model": DetailModel}}, + operation_id="get_election_by_name", ) -async def get_election( - request: Request, - db_session: database.DBSession, - election_name: str -): +async def get_election(db_session: database.DBSession, computing_id: SessionUser, election_name: str): current_time = datetime.datetime.now() slugified_name = slugify(election_name) election = await elections.crud.get_election(db_session, slugified_name) if election is None: raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"election with slug {slugified_name} does not exist" + status_code=status.HTTP_404_NOT_FOUND, detail=f"election with slug {slugified_name} does not exist" ) - is_valid_user, _, _ = await get_election_permissions(request, db_session) - if current_time >= election.datetime_start_voting or is_valid_user: - + has_permission = is_user_election_officer(computing_id, db_session) + if current_time >= election.datetime_start_voting or has_permission: election_json = election.private_details(current_time) all_nominations = await registrations.crud.get_all_registrations_in_election(db_session, slugified_name) if not all_nominations: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="no registrations found" - ) + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="no registrations found") election_json["candidates"] = [] available_positions_list = election.available_positions @@ -175,13 +141,9 @@ async def get_election( "instagram": nominee_info.instagram, "email": nominee_info.email, "discord_username": nominee_info.discord_username, - "speech": ( - "No speech provided by this candidate" - if nomination.speech is None - else nomination.speech - ), + "speech": ("No speech provided by this candidate" if nomination.speech is None else nomination.speech), } - if is_valid_user: + if has_permission: candidate_entry["computing_id"] = nomination.computing_id election_json["candidates"].append(candidate_entry) @@ -192,18 +154,19 @@ async def get_election( return JSONResponse(election_json) + @router.post( "", description="Creates an election and places it in the database. Returns election json on success", response_model=ElectionResponse, responses={ - 400: { "description": "Invalid request.", "model": DetailModel }, - 500: { "model": DetailModel }, + 400: {"description": "Invalid request.", "model": DetailModel}, + 500: {"model": DetailModel}, }, - operation_id="create_election" + operation_id="create_election", + dependencies=[Depends(perm_election)], ) async def create_election( - request: Request, body: ElectionParams, db_session: database.DBSession, ): @@ -211,7 +174,7 @@ async def create_election( if body.type not in ElectionTypeEnum: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=f"invalid election type {body.type} for available positions" + detail=f"invalid election type {body.type} for available positions", ) available_positions = _default_election_positions(body.type) else: @@ -225,50 +188,39 @@ async def create_election( # TODO: We might be able to just use a validation function from Pydantic or SQLAlchemy to check this _raise_if_bad_election_data( - slugified_name, - body.type, - start_nominations, - start_voting, - end_voting, - available_positions + slugified_name, body.type, start_nominations, start_voting, end_voting, available_positions ) - is_valid_user, _, _ = await get_election_permissions(request, db_session) - if not is_valid_user: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="must have election officer or admin permission" - ) - elif await elections.crud.get_election(db_session, slugified_name) is not None: + if await elections.crud.get_election(db_session, slugified_name) is not None: # don't overwrite a previous election raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="would overwrite previous election", + detail="election already exists", ) await elections.crud.create_election( db_session, Election( - slug = slugified_name, - name = body.name, - type = body.type, - datetime_start_nominations = start_nominations, - datetime_start_voting = start_voting, - datetime_end_voting = end_voting, - available_positions = available_positions, - survey_link = body.survey_link - ) + slug=slugified_name, + name=body.name, + type=body.type, + datetime_start_nominations=start_nominations, + datetime_start_voting=start_voting, + datetime_end_voting=end_voting, + available_positions=available_positions, + survey_link=body.survey_link, + ), ) await db_session.commit() election = await elections.crud.get_election(db_session, slugified_name) if election is None: raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="couldn't fetch newly created election" + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="couldn't fetch newly created election" ) return JSONResponse(election.private_details(current_time)) + @router.patch( "/{election_name:str}", description=""" @@ -281,25 +233,18 @@ async def create_election( """, response_model=ElectionResponse, responses={ - 400: { "model": DetailModel }, - 401: { "description": "Bad request", "model": DetailModel }, - 500: { "description": "Failed to find updated election", "model": DetailModel } + 400: {"model": DetailModel}, + 401: {"description": "Bad request", "model": DetailModel}, + 500: {"description": "Failed to find updated election", "model": DetailModel}, }, - operation_id="update_election" + operation_id="update_election", + dependencies=[Depends(perm_election)], ) async def update_election( - request: Request, body: ElectionUpdateParams, db_session: database.DBSession, election_name: str, ): - is_valid_user, _, _ = await get_election_permissions(request, db_session) - if not is_valid_user: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="must have election officer or admin permission" - ) - slugified_name = slugify(election_name) election = await elections.crud.get_election(db_session, slugified_name) if not election: @@ -322,10 +267,7 @@ async def update_election( # NOTE: If you update available positions, people will still *technically* be able to update their # registrations, however they will not be returned in the results. - await elections.crud.update_election( - db_session, - election - ) + await elections.crud.update_election(db_session, election) await db_session.commit() @@ -334,27 +276,17 @@ async def update_election( raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="couldn't find updated election") return JSONResponse(election.private_details(datetime.datetime.now())) + @router.delete( - "/{election_name:str}", + "/{election_name}", description="Deletes an election from the database. Returns whether the election exists after deletion.", response_model=SuccessResponse, - responses={ - 401: { "description": "Need to be logged in as an admin.", "model": DetailModel } - }, - operation_id="delete_election" + responses={401: {"description": "Need to be logged in as an admin.", "model": DetailModel}}, + operation_id="delete_election", + dependencies=[Depends(perm_election)], ) -async def delete_election( - request: Request, - db_session: database.DBSession, - election_name: str -): +async def delete_election(db_session: database.DBSession, election_name: str): slugified_name = slugify(election_name) - is_valid_user, _, _ = await get_election_permissions(request, db_session) - if not is_valid_user: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="must have election officer permission" - ) await elections.crud.delete_election(db_session, slugified_name) await db_session.commit() diff --git a/src/exambank/watermark.py b/src/exambank/watermark.py index 7f124d60..a2c4a7cd 100644 --- a/src/exambank/watermark.py +++ b/src/exambank/watermark.py @@ -11,12 +11,10 @@ # param to describe margin for exam generation text BORDER = 20 -def create_watermark( - computing_id: str, - density: int = 5 -) -> BytesIO: + +def create_watermark(computing_id: str, density: int = 5) -> BytesIO: """ - Returns a PDF with one page containing the watermark as text. + Returns a PDF with one page containing the watermark as text. """ # Generate the tiling watermark stamp_buffer = BytesIO() @@ -28,8 +26,8 @@ def create_watermark( width, height = A4 - for i in range(1, 4 * int(width), int(width/density)): - for j in range(1, 4 * int(height), int(height/density)): + for i in range(1, 4 * int(width), int(width / density)): + for j in range(1, 4 * int(height), int(height / density)): stamp_pdf.drawCentredString(i, j, computing_id) stamp_pdf.save() stamp_buffer.seek(0) @@ -41,6 +39,7 @@ def create_watermark( 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() @@ -59,10 +58,11 @@ def create_watermark( watermark_buffer.seek(0) 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] @@ -80,10 +80,8 @@ def apply_watermark( return watermarked_pdf -def raster_pdf( - pdf_path: BytesIO, - dpi: int = 300 -) -> BytesIO: + +def raster_pdf(pdf_path: BytesIO, dpi: int = 300) -> BytesIO: raster_buffer = BytesIO() # adapted from https://github.com/pymupdf/PyMuPDF/discussions/1183 with pymupdf.open(stream=pdf_path) as doc: @@ -100,10 +98,8 @@ def raster_pdf( raster_buffer.seek(0) return raster_buffer -def raster_pdf_from_path( - pdf_path: Path | str, - dpi: int = 300 -) -> BytesIO: + +def raster_pdf_from_path(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: diff --git a/src/github/__init__.py b/src/github/__init__.py index df498f8b..45baac01 100644 --- a/src/github/__init__.py +++ b/src/github/__init__.py @@ -5,7 +5,7 @@ from github.internals import add_user_to_team, list_members, list_team_members, list_teams, remove_user_from_team from github.types import GithubUserPermissions -#from admin.email import send_email +# from admin.email import send_email from officers.constants import OfficerPosition # Rules: @@ -19,24 +19,20 @@ GITHUB_TEAMS = { "doa": "auto", "election_officer": "auto", - "officers": "auto", # TODO: create the past_officers team "past_officers": "auto", - "w3_committee": "manual", "wall_e": "manual", } -AUTO_GITHUB_TEAMS = [ - name - for (name, kind) in GITHUB_TEAMS.items() - if kind == "auto" -] +AUTO_GITHUB_TEAMS = [name for (name, kind) in GITHUB_TEAMS.items() if kind == "auto"] + def is_active() -> bool: # if there is no github token, then consider the module inactive; calling functions may fail without warning! return os.environ.get("GITHUB_TOKEN") is not None + def officer_teams(position: str) -> list[str]: if position == OfficerPosition.DIRECTOR_OF_ARCHIVES: return ["doa", "officers"] @@ -45,14 +41,16 @@ def officer_teams(position: str) -> list[str]: else: return ["officers"] + # TODO: move these functions to github.public.py + def all_permissions() -> dict[str, GithubUserPermissions]: """ return a list of members in the organization (org) & their permissions """ member_list = list_members() - member_name_list = { member.name for member in member_list } + member_name_list = {member.name for member in member_list} team_list = [] for team in list_teams(): @@ -70,15 +68,14 @@ def all_permissions() -> dict[str, GithubUserPermissions]: # send_email("csss-sysadmin@sfu.ca", "ERROR: Missing Team", "...") _logger.error(f"Could not find 'auto' team {team_name} in organization") - user_permissions = { - user.username: GithubUserPermissions(user.username, []) - for user in member_list - } + user_permissions = {user.username: GithubUserPermissions(user.username, []) for user in member_list} for team in team_list: team_members = list_team_members(team.slug) for member in team_members: if member.name not in member_name_list: - _logger.warning(f"Found unexpected team_member={member.name} in team_slug={team.slug} not in the organization") + _logger.warning( + f"Found unexpected team_member={member.name} in team_slug={team.slug} not in the organization" + ) continue user_permissions[member.username].teams += [team.slug] @@ -89,6 +86,7 @@ def all_permissions() -> dict[str, GithubUserPermissions]: return user_permissions, team_id_map + def set_user_teams(username: str, old_teams: list[str], new_teams: list[str]): for team_slug in old_teams: if team_slug not in new_teams: @@ -99,8 +97,8 @@ def set_user_teams(username: str, old_teams: list[str], new_teams: list[str]): # TODO: what happens when adding a user to a team who is not part of the github org yet? add_user_to_team(username, team_slug) + def invite_user(github_username: str, teams: str): # invite this user to the github organization # TODO: is an invited user considered a member of the organization? pass - diff --git a/src/github/internals.py b/src/github/internals.py index c74ec3de..6a550667 100644 --- a/src/github/internals.py +++ b/src/github/internals.py @@ -13,17 +13,15 @@ # TODO: go through this module & make sure that all functions check for response.status_code # being invalid as specified by the API endpoints -async def _github_request_get( - url: str, - token: str -) -> Response | None: + +async def _github_request_get(url: str, token: str) -> Response | None: result = requests.get( url, headers={ "Accept": "application/vnd.github+json", "Authorization": f"Bearer {token}", - "X-GitHub-Api-Version": "2022-11-28" - } + "X-GitHub-Api-Version": "2022-11-28", + }, ) rate_limit_remaining = int(result.headers["x-ratelimit-remaining"]) if rate_limit_remaining < 50: @@ -32,19 +30,16 @@ async def _github_request_get( return result -async def _github_request_post( - url: str, - token: str, - post_data: Any -) -> Response | None: + +async def _github_request_post(url: str, token: str, post_data: Any) -> Response | None: result = requests.post( url, headers={ "Accept": "application/vnd.github+json", "Authorization": f"Bearer {token}", - "X-GitHub-Api-Version": "2022-11-28" + "X-GitHub-Api-Version": "2022-11-28", }, - data=post_data + data=post_data, ) rate_limit_remaining = int(result.headers["x-ratelimit-remaining"]) if rate_limit_remaining < 50: @@ -53,17 +48,15 @@ async def _github_request_post( return result -async def _github_request_delete( - url: str, - token: str -) -> Response | None: + +async def _github_request_delete(url: str, token: str) -> Response | None: result = requests.delete( url, - headers = { + headers={ "Accept": "application/vnd.github+json", "Authorization": f"Bearer {token}", - "X-GitHub-Api-Version": "2022-11-28" - } + "X-GitHub-Api-Version": "2022-11-28", + }, ) rate_limit_remaining = int(result.headers["x-ratelimit-remaining"]) if rate_limit_remaining < 50: @@ -72,18 +65,16 @@ async def _github_request_delete( return result -async def _github_request_put( - url: str, - token: str, - put_data: Any -) -> Response | None: + +async def _github_request_put(url: str, token: str, put_data: Any) -> Response | None: result = requests.put( url, headers={ "Accept": "application/vnd.github+json", "Authorization": f"Bearer {token}", - "X-GitHub-Api-Version": "2022-11-28"}, - data=put_data + "X-GitHub-Api-Version": "2022-11-28", + }, + data=put_data, ) rate_limit_remaining = int(result.headers["x-ratelimit-remaining"]) if rate_limit_remaining < 50: @@ -92,13 +83,12 @@ async def _github_request_put( return result -async def get_user_by_username( - username: str -) -> GithubUser | None: + +async def get_user_by_username(username: str) -> GithubUser | None: """ - Takes in a Github username and returns an instance of GithubUser. + Takes in a Github username and returns an instance of GithubUser. - Returns None if no such user was found. + Returns None if no such user was found. """ result = await _github_request_get( f"https://api.github.com/users/{username}", @@ -110,13 +100,12 @@ async def get_user_by_username( result_json = result.json() return GithubUser(result_json["login"], result_json["id"], result_json["name"]) -async def get_user_by_id( - uid: str -) -> GithubUser | None: + +async def get_user_by_id(uid: str) -> GithubUser | None: """ - Takes in a Github user id and returns an instance of GithubUser. + Takes in a Github user id and returns an instance of GithubUser. - Returns None if no such user was found. + Returns None if no such user was found. """ result = await _github_request_get( f"https://api.github.com/user/{uid}", @@ -128,8 +117,10 @@ async def get_user_by_id( result_json = result.json() return GithubUser(result_json["login"], result_json["id"], result_json["name"]) + # TODO: if needed, add support for getting user by email + # TODO: can we revoke access before an invite is accepeted? async def invite_user( uid: str, @@ -144,7 +135,7 @@ async def invite_user( result = await _github_request_post( f"https://api.github.com/orgs/{org}/invitations", GITHUB_TOKEN, - dumps({"invitee_id":uid, "role":"direct_member", "team_ids":team_id_list}) + dumps({"invitee_id": uid, "role": "direct_member", "team_ids": team_id_list}), ) # Logging here potentially? @@ -155,70 +146,55 @@ async def invite_user( f"{result_json['message']}: {[error['message'] for error in result_json['errors']]}" ) -async def delete_user_from_org( - username: str | None, - org: str = GITHUB_ORG_NAME -) -> None: + +async def delete_user_from_org(username: str | None, org: str = GITHUB_ORG_NAME) -> None: if username is None: raise ValueError("Username cannot be empty") - result = await _github_request_delete( - f"https://api.github.com/orgs/{org}/memberships/{username}", GITHUB_TOKEN - ) + result = await _github_request_delete(f"https://api.github.com/orgs/{org}/memberships/{username}", GITHUB_TOKEN) # Logging here potentially? if result.status_code != 204: - raise Exception(f"Status code {result.status_code} returned when attempting to delete user {username} from organization {org}") + raise Exception( + f"Status code {result.status_code} returned when attempting to delete user {username} from organization {org}" + ) + -async def list_teams( - org: str = GITHUB_ORG_NAME -) -> list[str]: +async def list_teams(org: str = GITHUB_ORG_NAME) -> list[str]: result = await _github_request_get(f"https://api.github.com/orgs/{org}/teams", GITHUB_TOKEN) - return [ - GithubTeam(team["id"], team["url"], team["name"], team["slug"]) - for team in result.json() - ] - -async def list_team_members( - team_slug: str, - org: str = GITHUB_ORG_NAME -): - result = await _github_request_get( - f"https://api.github.com/orgs/{org}/teams/{team_slug}/members", - GITHUB_TOKEN - ) - return [ - GithubUser(user["login"], user["id"], user["name"]) - for user in result.json() - ] - -async def add_user_to_team( - username: str, - team_slug: str, - org: str = GITHUB_ORG_NAME -) -> None: + return [GithubTeam(team["id"], team["url"], team["name"], team["slug"]) for team in result.json()] + + +async def list_team_members(team_slug: str, org: str = GITHUB_ORG_NAME): + result = await _github_request_get(f"https://api.github.com/orgs/{org}/teams/{team_slug}/members", GITHUB_TOKEN) + return [GithubUser(user["login"], user["id"], user["name"]) for user in result.json()] + + +async def add_user_to_team(username: str, team_slug: str, org: str = GITHUB_ORG_NAME) -> None: result = await _github_request_put( f"https://api.github.com/orgs/{org}/teams/{team_slug}/memberships/{username}", GITHUB_TOKEN, - dumps({"role":"member"}), + dumps({"role": "member"}), ) # Logging here potentially? if result.status_code != 200: result_json = result.json() - raise Exception(f"Status code {result.status_code} returned when attempting to add user to team: {result_json['message']}") + raise Exception( + f"Status code {result.status_code} returned when attempting to add user to team: {result_json['message']}" + ) -async def remove_user_from_team( - username: str, - team_slug: str, - org: str = GITHUB_ORG_NAME -) -> None: + +async def remove_user_from_team(username: str, team_slug: str, org: str = GITHUB_ORG_NAME) -> None: result = await _github_request_delete( f"https://api.github.com/orgs/{org}/teams/{team_slug}/memberships/{username}", GITHUB_TOKEN, ) if result.status_code != 204: - raise Exception(f"Status code {result.status_code} returned when attempting to delete user {username} from team {team_slug}") + raise Exception( + f"Status code {result.status_code} returned when attempting to delete user {username} from team {team_slug}" + ) + async def list_members( org: str = GITHUB_ORG_NAME, @@ -226,14 +202,10 @@ async def list_members( page_size: int = 99, ) -> list[GithubUser]: result = await _github_request_get( - f"https://api.github.com/orgs/{org}/members?per_page={page_size}&page={page_number}", - GITHUB_TOKEN + f"https://api.github.com/orgs/{org}/members?per_page={page_size}&page={page_number}", GITHUB_TOKEN ) if result.status_code != 200: raise Exception(f"Got result with status_code={result.status_code}, and contents={result.text}") - return [ - (user["login"], user["id"]) - for user in result.json() - ] + return [(user["login"], user["id"]) for user in result.json()] diff --git a/src/github/types.py b/src/github/types.py index 9e5f48ad..d63d60ab 100644 --- a/src/github/types.py +++ b/src/github/types.py @@ -7,6 +7,7 @@ class GithubUser: id: int name: str + @dataclass class GithubTeam: id: int @@ -15,6 +16,7 @@ class GithubTeam: # slugs are the space-free special names that github likes to use slug: str + @dataclass class GithubUserPermissions: # this class should store all the possible permissions a user might have diff --git a/src/google_api/internals.py b/src/google_api/internals.py index 22460762..d8cb5938 100644 --- a/src/google_api/internals.py +++ b/src/google_api/internals.py @@ -7,62 +7,59 @@ # TODO: understand how these work credentials = service_account.Credentials.from_service_account_file( - filename=SERVICE_ACCOUNT_KEY_PATH, - scopes=GOOGLE_API_SCOPES + filename=SERVICE_ACCOUNT_KEY_PATH, scopes=GOOGLE_API_SCOPES ) delegated_credentials = credentials.with_subject(GOOGLE_WORKSPACE_ACCOUNT) service = build("drive", "v3", credentials=delegated_credentials) + def _list_shared_drives() -> list: return ( - service - .drives() + service.drives() .list( - #pageSize = 50, - #q = "name contains 'CSSS'", - #useDomainAdminAccess = True, + # pageSize = 50, + # q = "name contains 'CSSS'", + # useDomainAdminAccess = True, ) .execute() ) + def list_drive_permissions(drive_id: str) -> list: return ( - service - .permissions() + service.permissions() .list( - fileId = drive_id, + fileId=drive_id, # important to find the shared drive - supportsAllDrives = True, - fields = "*", + supportsAllDrives=True, + fields="*", ) .execute() ) + def create_drive_permission(drive_id: str, permission: dict): return ( - service - .permissions() + service.permissions() .create( - fileId = drive_id, - + fileId=drive_id, # TODO: update message - emailMessage = "You were just given permission to an SFU CSSS shared google drive!", - sendNotificationEmail = True, - supportsAllDrives = True, - + emailMessage="You were just given permission to an SFU CSSS shared google drive!", + sendNotificationEmail=True, + supportsAllDrives=True, body=permission, ) .execute() ) + def delete_drive_permission(drive_id: str, permission_id: str): return ( - service - .permissions() + service.permissions() .delete( - fileId = drive_id, - permissionId = permission_id, - supportsAllDrives = True, + fileId=drive_id, + permissionId=permission_id, + supportsAllDrives=True, ) .execute() ) diff --git a/src/load_test_db.py b/src/load_test_db.py index e59f3a18..5aebd43c 100644 --- a/src/load_test_db.py +++ b/src/load_test_db.py @@ -23,7 +23,7 @@ update_officer_info, update_officer_term, ) -from officers.tables import OfficerInfo, OfficerTerm +from officers.tables import OfficerInfoDB, OfficerTermDB from registrations.crud import add_registration from registrations.tables import NomineeApplication @@ -31,9 +31,7 @@ async def reset_db(engine): # reset db async with engine.connect() as conn: - table_list = await conn.run_sync( - lambda sync_conn: sqlalchemy.inspect(sync_conn).get_table_names() - ) + table_list = await conn.run_sync(lambda sync_conn: sqlalchemy.inspect(sync_conn).get_table_names()) if len(table_list) != 0: print(f"found tables to delete: {table_list}") @@ -44,9 +42,7 @@ async def reset_db(engine): # check tables in db async with engine.connect() as conn: - table_list = await conn.run_sync( - lambda sync_conn: sqlalchemy.inspect(sync_conn).get_table_names() - ) + table_list = await conn.run_sync(lambda sync_conn: sqlalchemy.inspect(sync_conn).get_table_names()) if len(table_list) != 0: # TODO: replace this with logging print("FAILED TO REMOVE TABLES, THIS IS NOT GONNA BE FUN") @@ -62,22 +58,23 @@ async def reset_db(engine): # check tables in db async with engine.connect() as conn: - table_list = await conn.run_sync( - lambda sync_conn: sqlalchemy.inspect(sync_conn).get_table_names() - ) + table_list = await conn.run_sync(lambda sync_conn: sqlalchemy.inspect(sync_conn).get_table_names()) if len(table_list) == 0: print("Uh oh, failed to create any tables...") else: print(f"new tables: {table_list}") + # ----------------------------------------------------------------- # # load db with test data + async def load_test_auth_data(db_session: AsyncSession): await create_user_session(db_session, "temp_id_314", "abc314") 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") @@ -87,350 +84,384 @@ async def load_test_officers_data(db_session: AsyncSession): print("add officer info") # this person has uploaded all of their info - await create_new_officer_info(db_session, OfficerInfo( - legal_name="Person A", - discord_id=str(88_1234_7182_4877_1111), - discord_name="person_a_yeah", - discord_nickname="aaa", - - computing_id="abc11", - phone_number="1234567890", - github_username="person_a", - google_drive_email="person_a@gmail.com", - )) + await create_new_officer_info( + db_session, + OfficerInfoDB( + legal_name="Person A", + discord_id=str(88_1234_7182_4877_1111), + discord_name="person_a_yeah", + discord_nickname="aaa", + computing_id="abc11", + phone_number="1234567890", + github_username="person_a", + google_drive_email="person_a@gmail.com", + ), + ) # this person has not joined the CSSS discord, so their discord name & nickname could not be found - await create_new_officer_info(db_session, OfficerInfo( - computing_id="abc22", - - legal_name="Person B", - phone_number="1112223333", - - discord_id=str(88_1234_7182_4877_2222), - discord_name=None, - discord_nickname=None, - - google_drive_email="person_b@gmail.com", - github_username="person_b", - )) + await create_new_officer_info( + db_session, + OfficerInfoDB( + computing_id="abc22", + legal_name="Person B", + phone_number="1112223333", + discord_id=str(88_1234_7182_4877_2222), + discord_name=None, + discord_nickname=None, + google_drive_email="person_b@gmail.com", + github_username="person_b", + ), + ) # this person has uploaded the minimal amount of information - await create_new_officer_info(db_session, OfficerInfo( - legal_name="Person C", - discord_id=None, - discord_name=None, - discord_nickname=None, - - computing_id="abc33", - phone_number=None, - github_username=None, - google_drive_email=None, - )) + await create_new_officer_info( + db_session, + OfficerInfoDB( + legal_name="Person C", + discord_id=None, + discord_name=None, + discord_nickname=None, + computing_id="abc33", + phone_number=None, + github_username=None, + google_drive_email=None, + ), + ) await db_session.commit() - await create_new_officer_term(db_session, OfficerTerm( - computing_id="abc11", - - position=OfficerPositionEnum.VICE_PRESIDENT, - start_date=date.today() - timedelta(days=365), - end_date=date.today() - timedelta(days=1), - - nickname="the A", - favourite_course_0="CMPT 125", - favourite_course_1="CA 149", - - favourite_pl_0="Turbo Pascal", - favourite_pl_1="BASIC", - - biography="Hi! I'm person A and I do lots of cool things! :)", - photo_url=None, # TODO: this should be replaced with a default image - )) - await create_new_officer_term(db_session, OfficerTerm( - computing_id="abc11", - - position=OfficerPositionEnum.EXECUTIVE_AT_LARGE, - start_date=date.today(), - end_date=None, - - nickname="the holy A", - favourite_course_0="CMPT 361", - favourite_course_1="MACM 316", - - favourite_pl_0="Turbo Pascal", - favourite_pl_1="Rust", - - biography="Hi! I'm person A and I want school to be over ; _ ;", - photo_url=None, # TODO: this should be replaced with a default image - )) - await create_new_officer_term(db_session, OfficerTerm( - computing_id="abc33", - - position=OfficerPositionEnum.PRESIDENT, - start_date=date.today(), - end_date=date.today() + timedelta(days=365), - - nickname="CC", - favourite_course_0="CMPT 999", - favourite_course_1="CMPT 354", - - favourite_pl_0="C++", - favourite_pl_1="C", - - biography="I'm person C...", - photo_url=None, # TODO: this should be replaced with a default image - )) + await create_new_officer_term( + db_session, + OfficerTermDB( + computing_id="abc11", + position=OfficerPositionEnum.VICE_PRESIDENT, + start_date=date.today() - timedelta(days=365), + end_date=date.today() - timedelta(days=1), + nickname="the A", + favourite_course_0="CMPT 125", + favourite_course_1="CA 149", + favourite_pl_0="Turbo Pascal", + favourite_pl_1="BASIC", + biography="Hi! I'm person A and I do lots of cool things! :)", + photo_url=None, # TODO: this should be replaced with a default image + ), + ) + await create_new_officer_term( + db_session, + OfficerTermDB( + computing_id="abc11", + position=OfficerPositionEnum.EXECUTIVE_AT_LARGE, + start_date=date.today(), + end_date=None, + nickname="the holy A", + favourite_course_0="CMPT 361", + favourite_course_1="MACM 316", + favourite_pl_0="Turbo Pascal", + favourite_pl_1="Rust", + biography="Hi! I'm person A and I want school to be over ; _ ;", + photo_url=None, # TODO: this should be replaced with a default image + ), + ) + await create_new_officer_term( + db_session, + OfficerTermDB( + computing_id="abc33", + position=OfficerPositionEnum.PRESIDENT, + start_date=date.today(), + end_date=date.today() + timedelta(days=365), + nickname="CC", + favourite_course_0="CMPT 999", + favourite_course_1="CMPT 354", + favourite_pl_0="C++", + favourite_pl_1="C", + biography="I'm person C...", + photo_url=None, # TODO: this should be replaced with a default image + ), + ) # this officer term is not fully filled in - await create_new_officer_term(db_session, OfficerTerm( - computing_id="abc22", - - position=OfficerPositionEnum.DIRECTOR_OF_ARCHIVES, - start_date=date.today(), - end_date=date.today() + timedelta(days=365), - - nickname="Bee", - favourite_course_0="CMPT 604", - favourite_course_1=None, - - favourite_pl_0="B", - favourite_pl_1="N/A", - - biography=None, - photo_url=None, # TODO: this should be replaced with a default image - )) + await create_new_officer_term( + db_session, + OfficerTermDB( + computing_id="abc22", + position=OfficerPositionEnum.DIRECTOR_OF_ARCHIVES, + start_date=date.today(), + end_date=date.today() + timedelta(days=365), + nickname="Bee", + favourite_course_0="CMPT 604", + favourite_course_1=None, + favourite_pl_0="B", + favourite_pl_1="N/A", + biography=None, + photo_url=None, # TODO: this should be replaced with a default image + ), + ) await db_session.commit() - await update_officer_info(db_session, OfficerInfo( - legal_name="Person C ----", - discord_id=None, - discord_name=None, - discord_nickname=None, - - computing_id="abc33", - # adds a phone number - phone_number="123-456-7890", - github_username=None, - google_drive_email=None, - )) - await update_officer_term(db_session, OfficerTerm( - computing_id="abc33", - - position=OfficerPositionEnum.PRESIDENT, - start_date=date.today(), - end_date=date.today() + timedelta(days=365), - - nickname="SEE SEE", - favourite_course_0="CMPT 999", - favourite_course_1="CMPT 354", - - favourite_pl_0="C++", - favourite_pl_1="C", - - biography="You see, I'm person C...", - photo_url=None, - )) + await update_officer_info( + db_session, + OfficerInfoDB( + legal_name="Person C ----", + discord_id=None, + discord_name=None, + discord_nickname=None, + computing_id="abc33", + # adds a phone number + phone_number="123-456-7890", + github_username=None, + google_drive_email=None, + ), + ) + await update_officer_term( + db_session, + OfficerTermDB( + computing_id="abc33", + position=OfficerPositionEnum.PRESIDENT, + start_date=date.today(), + end_date=date.today() + timedelta(days=365), + nickname="SEE SEE", + favourite_course_0="CMPT 999", + favourite_course_1="CMPT 354", + favourite_pl_0="C++", + favourite_pl_1="C", + biography="You see, I'm person C...", + photo_url=None, + ), + ) await db_session.commit() + SYSADMIN_COMPUTING_ID = "pkn4" + + 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_new_officer_info(db_session, OfficerInfo( - legal_name="Puneet North", - discord_id=None, - discord_name=None, - discord_nickname=None, - - computing_id=SYSADMIN_COMPUTING_ID, - phone_number=None, - github_username=None, - google_drive_email=None, - )) - await create_new_officer_term(db_session, OfficerTerm( - computing_id=SYSADMIN_COMPUTING_ID, - - position=OfficerPositionEnum.FIRST_YEAR_REPRESENTATIVE, - start_date=date.today() - timedelta(days=(365*3)), - end_date=date.today() - timedelta(days=(365*2)), - - nickname="G1", - favourite_course_0="MACM 101", - favourite_course_1="CMPT 125", - - favourite_pl_0="C#", - favourite_pl_1="C++", - - biography="o hey fellow kids \n\n\n I can newline", - photo_url=None, - )) - await create_new_officer_term(db_session, OfficerTerm( - computing_id=SYSADMIN_COMPUTING_ID, - - position=OfficerPositionEnum.SYSTEM_ADMINISTRATOR, - start_date=date.today() - timedelta(days=365), - end_date=None, - - nickname="G2", - favourite_course_0="CMPT 379", - favourite_course_1="CMPT 295", - - favourite_pl_0="Rust", - favourite_pl_1="C", - - biography="The systems are good o7", - photo_url=None, - )) + await create_new_officer_info( + db_session, + OfficerInfoDB( + legal_name="Puneet North", + discord_id=None, + discord_name=None, + discord_nickname=None, + computing_id=SYSADMIN_COMPUTING_ID, + phone_number=None, + github_username=None, + google_drive_email=None, + ), + ) + await create_new_officer_term( + db_session, + OfficerTermDB( + computing_id=SYSADMIN_COMPUTING_ID, + position=OfficerPositionEnum.FIRST_YEAR_REPRESENTATIVE, + start_date=date.today() - timedelta(days=(365 * 3)), + end_date=date.today() - timedelta(days=(365 * 2)), + nickname="G1", + favourite_course_0="MACM 101", + favourite_course_1="CMPT 125", + favourite_pl_0="C#", + favourite_pl_1="C++", + biography="o hey fellow kids \n\n\n I can newline", + photo_url=None, + ), + ) + await create_new_officer_term( + db_session, + OfficerTermDB( + computing_id=SYSADMIN_COMPUTING_ID, + position=OfficerPositionEnum.SYSTEM_ADMINISTRATOR, + start_date=date.today() - timedelta(days=365), + end_date=None, + nickname="G2", + favourite_course_0="CMPT 379", + favourite_course_1="CMPT 295", + favourite_pl_0="Rust", + favourite_pl_1="C", + biography="The systems are good o7", + photo_url=None, + ), + ) # a future term - await create_new_officer_term(db_session, OfficerTerm( - computing_id=SYSADMIN_COMPUTING_ID, - - position=OfficerPositionEnum.DIRECTOR_OF_ARCHIVES, - start_date=date.today() + timedelta(days=365*1), - end_date=date.today() + timedelta(days=365*2), + await create_new_officer_term( + db_session, + OfficerTermDB( + computing_id=SYSADMIN_COMPUTING_ID, + position=OfficerPositionEnum.DIRECTOR_OF_ARCHIVES, + start_date=date.today() + timedelta(days=365 * 1), + end_date=date.today() + timedelta(days=365 * 2), + nickname="G3", + favourite_course_0="MACM 102", + favourite_course_1="CMPT 127", + favourite_pl_0="C%", + favourite_pl_1="C$$", + biography="o hey fellow kids \n\n\n I will can newline .... !!", + photo_url=None, + ), + ) + await db_session.commit() - nickname="G3", - favourite_course_0="MACM 102", - favourite_course_1="CMPT 127", - favourite_pl_0="C%", - favourite_pl_1="C$$", +WEBMASTER_COMPUTING_ID = "jbriones" - biography="o hey fellow kids \n\n\n I will can newline .... !!", - photo_url=None, - )) - await db_session.commit() -WEBMASTER_COMPUTING_ID = "jbriones" async def load_webmaster(db_session: AsyncSession): # put your computing id here for testing purposes print(f"loading new webmaster '{WEBMASTER_COMPUTING_ID}'") await create_user_session(db_session, f"temp_id_{WEBMASTER_COMPUTING_ID}", WEBMASTER_COMPUTING_ID) - await create_new_officer_info(db_session, OfficerInfo( - legal_name="Jon Andre Briones", - discord_id=None, - discord_name=None, - discord_nickname=None, - - computing_id=WEBMASTER_COMPUTING_ID, - phone_number=None, - github_username=None, - google_drive_email=None, - )) - await create_new_officer_term(db_session, OfficerTerm( - computing_id=WEBMASTER_COMPUTING_ID, - - position=OfficerPositionEnum.FIRST_YEAR_REPRESENTATIVE, - start_date=date.today() - timedelta(days=(365*3)), - end_date=date.today() - timedelta(days=(365*2)), - - nickname="Jon Andre Briones", - favourite_course_0="CMPT 379", - favourite_course_1="CMPT 371", - - favourite_pl_0="TypeScript", - favourite_pl_1="C#", - - biography="o hey fellow kids \n\n\n I can newline", - photo_url=None, - )) - await create_new_officer_term(db_session, OfficerTerm( - computing_id=WEBMASTER_COMPUTING_ID, - - position=OfficerPositionEnum.WEBMASTER, - start_date=date.today() - timedelta(days=365), - end_date=None, - - nickname="G2", - favourite_course_0="CMPT 379", - favourite_course_1="CMPT 295", - - favourite_pl_0="Rust", - favourite_pl_1="C", - - biography="The systems are good o7", - photo_url=None, - )) + await create_new_officer_info( + db_session, + OfficerInfoDB( + legal_name="Jon Andre Briones", + discord_id=None, + discord_name=None, + discord_nickname=None, + computing_id=WEBMASTER_COMPUTING_ID, + phone_number=None, + github_username=None, + google_drive_email=None, + ), + ) + await create_new_officer_term( + db_session, + OfficerTermDB( + computing_id=WEBMASTER_COMPUTING_ID, + position=OfficerPositionEnum.FIRST_YEAR_REPRESENTATIVE, + start_date=date.today() - timedelta(days=(365 * 3)), + end_date=date.today() - timedelta(days=(365 * 2)), + nickname="Jon Andre Briones", + favourite_course_0="CMPT 379", + favourite_course_1="CMPT 371", + favourite_pl_0="TypeScript", + favourite_pl_1="C#", + biography="o hey fellow kids \n\n\n I can newline", + photo_url=None, + ), + ) + await create_new_officer_term( + db_session, + OfficerTermDB( + computing_id=WEBMASTER_COMPUTING_ID, + position=OfficerPositionEnum.WEBMASTER, + start_date=date.today() - timedelta(days=365), + end_date=None, + nickname="G2", + favourite_course_0="CMPT 379", + favourite_course_1="CMPT 295", + favourite_pl_0="Rust", + favourite_pl_1="C", + biography="The systems are good o7", + photo_url=None, + ), + ) await db_session.commit() + async def load_test_elections_data(db_session: AsyncSession): print("loading election data...") - await create_election(db_session, Election( - slug="test-election-1", - name="test election 1", - type="general_election", - datetime_start_nominations=datetime.now() - timedelta(days=400), - datetime_start_voting=datetime.now() - timedelta(days=395, hours=4), - datetime_end_voting=datetime.now() - timedelta(days=390, hours=8), - available_positions=["president", "vice-president"], - survey_link="https://youtu.be/dQw4w9WgXcQ?si=kZROi2tu-43MXPM5" - )) - await update_election(db_session, Election( - slug="test-election-1", - name="test election 1", - type="general_election", - datetime_start_nominations=datetime.now() - timedelta(days=400), - datetime_start_voting=datetime.now() - timedelta(days=395, hours=4), - datetime_end_voting=datetime.now() - timedelta(days=390, hours=8), - available_positions=["president", "vice-president", "treasurer"], - survey_link="https://youtu.be/dQw4w9WgXcQ?si=kZROi2tu-43MXPM5" - )) - await create_election(db_session, Election( - slug="test-election-2", - name="test election 2", - type="by_election", - datetime_start_nominations=datetime.now() - timedelta(days=1), - datetime_start_voting=datetime.now() + timedelta(days=7), - datetime_end_voting=datetime.now() + timedelta(days=14), - available_positions=["president", "vice-president", "treasurer"], - survey_link="https://youtu.be/dQw4w9WgXcQ?si=kZROi2tu-43MXPM5 (oh yeah)" - )) - await create_nominee_info(db_session, NomineeInfo( - computing_id = "jdo12", - full_name = "John Doe", - linked_in = "linkedin.com/john-doe", - instagram = "john_doe", - email = "john_doe@doe.com", - discord_username = "doedoe" - )) - await create_nominee_info(db_session, NomineeInfo( - computing_id = "pkn4", - full_name = "Puneet North", - linked_in = "linkedin.com/john-doe3", - instagram = "john_doe 3", - email = "john_do3e@doe.com", - discord_username = "doedoe3" - )) - await create_election(db_session, Election( - slug="my-cr-election-3", - name="my cr election 3", - type="council_rep_election", - datetime_start_nominations=datetime.now() - timedelta(days=5), - datetime_start_voting=datetime.now() - timedelta(days=1, hours=4), - datetime_end_voting=datetime.now() + timedelta(days=5, hours=8), - available_positions=["president", "vice-president" ,"treasurer"], - survey_link="https://youtu.be/dQw4w9WgXcQ?si=kZROi2tu-43MXPM5" - )) - await create_election(db_session, Election( - slug="THE-SUPER-GENERAL-ELECTION-friends", - name="THE SUPER GENERAL ELECTION & friends", - type="general_election", - datetime_start_nominations=datetime.now() + timedelta(days=5), - datetime_start_voting=datetime.now() + timedelta(days=10, hours=4), - datetime_end_voting=datetime.now() + timedelta(days=15, hours=8), - available_positions=["president" ,"vice-president", "treasurer"], - survey_link=None - )) + await create_election( + db_session, + Election( + slug="test-election-1", + name="test election 1", + type="general_election", + datetime_start_nominations=datetime.now() - timedelta(days=400), + datetime_start_voting=datetime.now() - timedelta(days=395, hours=4), + datetime_end_voting=datetime.now() - timedelta(days=390, hours=8), + available_positions=["president", "vice-president"], + survey_link="https://youtu.be/dQw4w9WgXcQ?si=kZROi2tu-43MXPM5", + ), + ) + await update_election( + db_session, + Election( + slug="test-election-1", + name="test election 1", + type="general_election", + datetime_start_nominations=datetime.now() - timedelta(days=400), + datetime_start_voting=datetime.now() - timedelta(days=395, hours=4), + datetime_end_voting=datetime.now() - timedelta(days=390, hours=8), + available_positions=["president", "vice-president", "treasurer"], + survey_link="https://youtu.be/dQw4w9WgXcQ?si=kZROi2tu-43MXPM5", + ), + ) + await create_election( + db_session, + Election( + slug="test-election-2", + name="test election 2", + type="by_election", + datetime_start_nominations=datetime.now() - timedelta(days=1), + datetime_start_voting=datetime.now() + timedelta(days=7), + datetime_end_voting=datetime.now() + timedelta(days=14), + available_positions=["president", "vice-president", "treasurer"], + survey_link="https://youtu.be/dQw4w9WgXcQ?si=kZROi2tu-43MXPM5 (oh yeah)", + ), + ) + await create_nominee_info( + db_session, + NomineeInfo( + computing_id="jdo12", + full_name="John Doe", + linked_in="linkedin.com/john-doe", + instagram="john_doe", + email="john_doe@doe.com", + discord_username="doedoe", + ), + ) + await create_nominee_info( + db_session, + NomineeInfo( + computing_id="pkn4", + full_name="Puneet North", + linked_in="linkedin.com/john-doe3", + instagram="john_doe 3", + email="john_do3e@doe.com", + discord_username="doedoe3", + ), + ) + await create_election( + db_session, + Election( + slug="my-cr-election-3", + name="my cr election 3", + type="council_rep_election", + datetime_start_nominations=datetime.now() - timedelta(days=5), + datetime_start_voting=datetime.now() - timedelta(days=1, hours=4), + datetime_end_voting=datetime.now() + timedelta(days=5, hours=8), + available_positions=["president", "vice-president", "treasurer"], + survey_link="https://youtu.be/dQw4w9WgXcQ?si=kZROi2tu-43MXPM5", + ), + ) + await create_election( + db_session, + Election( + slug="THE-SUPER-GENERAL-ELECTION-friends", + name="THE SUPER GENERAL ELECTION & friends", + type="general_election", + datetime_start_nominations=datetime.now() + timedelta(days=5), + datetime_start_voting=datetime.now() + timedelta(days=10, hours=4), + datetime_end_voting=datetime.now() + timedelta(days=15, hours=8), + available_positions=["president", "vice-president", "treasurer"], + survey_link=None, + ), + ) await db_session.commit() + async def load_test_election_nominee_application_data(db_session: AsyncSession): - await add_registration(db_session, NomineeApplication( - computing_id=SYSADMIN_COMPUTING_ID, - nominee_election="test-election-2", - position="vice-president", - speech=None - )) + await add_registration( + db_session, + NomineeApplication( + computing_id=SYSADMIN_COMPUTING_ID, + nominee_election="test-election-2", + position="vice-president", + speech=None, + ), + ) await db_session.commit() + # ----------------------------------------------------------------- # + async def async_main(sessionmanager): await reset_db(sessionmanager._engine) async with sessionmanager.session() as db_session: @@ -441,6 +472,7 @@ async def async_main(sessionmanager): await load_test_elections_data(db_session) await load_test_election_nominee_application_data(db_session) + if __name__ == "__main__": response = input(f"This will reset the {SQLALCHEMY_TEST_DATABASE_URL} database, are you okay with this? (y/N): ") if response.lower() != "y": diff --git a/src/main.py b/src/main.py index d326ee6f..a989ebd1 100755 --- a/src/main.py +++ b/src/main.py @@ -22,8 +22,8 @@ if not IS_PROD: print("Running local environment") origins = [ - "http://localhost:4200", # default Angular - "http://localhost:8080", # for existing applications/sites + "http://localhost:4200", # default Angular + "http://localhost:8080", # for existing applications/sites ] app = FastAPI( lifespan=database.lifespan, @@ -33,26 +33,18 @@ # if on production, disable viewing the docs else: print("Running production environment") - origins = [ - "https://sfucsss.org", - "https://test.sfucsss.org", - "https://admin.sfucsss.org" - ] + origins = ["https://sfucsss.org", "https://test.sfucsss.org", "https://admin.sfucsss.org"] app = FastAPI( lifespan=database.lifespan, title="CSSS Site Backend", root_path="/api", docs_url=None, # disables Swagger UI - redoc_url=None, # disables ReDoc - openapi_url=None # disables OpenAPI schema + redoc_url=None, # disables ReDoc + openapi_url=None, # disables OpenAPI schema ) app.add_middleware( - CORSMiddleware, - allow_origins=origins, - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"] + CORSMiddleware, allow_origins=origins, allow_credentials=True, allow_methods=["*"], allow_headers=["*"] ) app.include_router(auth.urls.router) @@ -62,10 +54,12 @@ app.include_router(officers.urls.router) app.include_router(permission.urls.router) + @app.get("/") async def read_root(): return {"message": "Hello! You might be lost, this is actually the sfucsss.org's backend api."} + @app.exception_handler(RequestValidationError) async def validation_exception_handler( _: Request, @@ -73,8 +67,10 @@ async def validation_exception_handler( ): return JSONResponse( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - content=jsonable_encoder({ - "detail": exception.errors(), - "body": exception.body, - }) + content=jsonable_encoder( + { + "detail": exception.errors(), + "body": exception.body, + } + ), ) diff --git a/src/nominees/crud.py b/src/nominees/crud.py index 372ed910..3b61e4c1 100644 --- a/src/nominees/crud.py +++ b/src/nominees/crud.py @@ -8,11 +8,8 @@ async def get_nominee_info( db_session: AsyncSession, computing_id: str, ) -> NomineeInfo | None: - return await db_session.scalar( - sqlalchemy - .select(NomineeInfo) - .where(NomineeInfo.computing_id == computing_id) - ) + return await db_session.scalar(sqlalchemy.select(NomineeInfo).where(NomineeInfo.computing_id == computing_id)) + async def create_nominee_info( db_session: AsyncSession, @@ -20,13 +17,13 @@ async def create_nominee_info( ): db_session.add(info) + async def update_nominee_info( db_session: AsyncSession, info: NomineeInfo, ): await db_session.execute( - sqlalchemy - .update(NomineeInfo) + sqlalchemy.update(NomineeInfo) .where(NomineeInfo.computing_id == info.computing_id) .values(info.to_update_dict()) ) diff --git a/src/nominees/models.py b/src/nominees/models.py index 095c1082..47a9ea4f 100644 --- a/src/nominees/models.py +++ b/src/nominees/models.py @@ -9,10 +9,10 @@ class NomineeInfoModel(BaseModel): email: str discord_username: str + class NomineeInfoUpdateParams(BaseModel): full_name: str | None = None linked_in: str | None = None instagram: str | None = None email: str | None = None discord_username: str | None = None - diff --git a/src/nominees/tables.py b/src/nominees/tables.py index 23dff179..afd60b6e 100644 --- a/src/nominees/tables.py +++ b/src/nominees/tables.py @@ -23,7 +23,6 @@ class NomineeInfo(Base): def to_update_dict(self) -> dict: return { "full_name": self.full_name, - "linked_in": self.linked_in, "instagram": self.instagram, "email": self.email, @@ -38,10 +37,8 @@ def serialize(self) -> dict: return { "computing_id": self.computing_id, "full_name": self.full_name, - "linked_in": self.linked_in, "instagram": self.instagram, "email": self.email, "discord_username": self.discord_username, } - diff --git a/src/nominees/urls.py b/src/nominees/urls.py index f1b6d9d2..875ce174 100644 --- a/src/nominees/urls.py +++ b/src/nominees/urls.py @@ -1,69 +1,54 @@ -from fastapi import APIRouter, HTTPException, Request, status +from fastapi import APIRouter, Depends, HTTPException, status from fastapi.responses import JSONResponse import database import nominees.crud +from dependencies import perm_election from nominees.models import ( NomineeInfoModel, NomineeInfoUpdateParams, ) from nominees.tables import NomineeInfo -from utils.urls import AdminTypeEnum, admin_or_raise router = APIRouter( prefix="/nominee", tags=["nominee"], ) + @router.get( "/{computing_id:str}", description="Nominee info is always publically tied to election, so be careful!", response_model=NomineeInfoModel, - responses={ - 404: { "description": "nominee doesn't exist" } - }, - operation_id="get_nominee" + responses={404: {"description": "nominee doesn't exist"}}, + operation_id="get_nominee", + dependencies=[Depends(perm_election)], ) -async def get_nominee_info( - request: Request, - db_session: database.DBSession, - computing_id: str -): +async def get_nominee_info(db_session: database.DBSession, computing_id: str): # Putting this one behind the admin wall since it has contact information - await admin_or_raise(request, db_session, AdminTypeEnum.Election) nominee_info = await nominees.crud.get_nominee_info(db_session, computing_id) if nominee_info is None: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="nominee doesn't exist" - ) + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="nominee doesn't exist") return JSONResponse(nominee_info.serialize()) + @router.patch( "/{computing_id:str}", description="Will create or update nominee info. Returns an updated copy of their nominee info.", response_model=NomineeInfoModel, - responses={ - 500: { "description": "Failed to retrieve updated nominee." } - }, - operation_id="update_nominee" + responses={500: {"description": "Failed to retrieve updated nominee."}}, + operation_id="update_nominee", + dependencies=[Depends(perm_election)], ) -async def provide_nominee_info( - request: Request, - db_session: database.DBSession, - body: NomineeInfoUpdateParams, - computing_id: str -): +async def provide_nominee_info(db_session: database.DBSession, body: NomineeInfoUpdateParams, computing_id: str): # TODO: There needs to be a lot more validation here. - await admin_or_raise(request, db_session, AdminTypeEnum.Election) - updated_data = {} # Only update fields that were provided if body.full_name is not None: updated_data["full_name"] = body.full_name if body.linked_in is not None: - updated_data["linked_in"] = body.linked_in + updated_data["linked_in"] = body.linked_in if body.instagram is not None: updated_data["instagram"] = body.instagram if body.email is not None: @@ -97,8 +82,5 @@ async def provide_nominee_info( nominee_info = await nominees.crud.get_nominee_info(db_session, computing_id) if not nominee_info: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="failed to get updated nominee" - ) + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="failed to get updated nominee") return JSONResponse(nominee_info.serialize()) diff --git a/src/officers/__init__.py b/src/officers/__init__.py index e69de29b..b3d348b3 100755 --- a/src/officers/__init__.py +++ b/src/officers/__init__.py @@ -0,0 +1 @@ +from officers import crud diff --git a/src/officers/constants.py b/src/officers/constants.py index 59aaf9c4..9d7cb7b7 100644 --- a/src/officers/constants.py +++ b/src/officers/constants.py @@ -4,6 +4,7 @@ OFFICER_POSITION_MAX = 128 OFFICER_LEGAL_NAME_MAX = 128 + class OfficerPositionEnum(StrEnum): PRESIDENT = "president" VICE_PRESIDENT = "vice-president" @@ -14,7 +15,7 @@ class OfficerPositionEnum(StrEnum): DIRECTOR_OF_EDUCATIONAL_EVENTS = "director of educational events" ASSISTANT_DIRECTOR_OF_EVENTS = "assistant director of events" DIRECTOR_OF_COMMUNICATIONS = "director of communications" - #DIRECTOR_OF_OUTREACH = "director of outreach" + # DIRECTOR_OF_OUTREACH = "director of outreach" DIRECTOR_OF_MULTIMEDIA = "director of multimedia" DIRECTOR_OF_ARCHIVES = "director of archives" EXECUTIVE_AT_LARGE = "executive at large" @@ -28,6 +29,7 @@ class OfficerPositionEnum(StrEnum): WEBMASTER = "webmaster" SOCIAL_MEDIA_MANAGER = "social media manager" + class OfficerPosition: @staticmethod def position_list() -> list[OfficerPositionEnum]: @@ -58,10 +60,7 @@ def num_active(position: str) -> int | None: or position == OfficerPositionEnum.FIRST_YEAR_REPRESENTATIVE ): return 2 - elif ( - position == OfficerPositionEnum.FROSH_WEEK_CHAIR - or position == OfficerPositionEnum.SOCIAL_MEDIA_MANAGER - ): + elif position == OfficerPositionEnum.FROSH_WEEK_CHAIR or position == OfficerPositionEnum.SOCIAL_MEDIA_MANAGER: return None else: return 1 @@ -86,47 +85,42 @@ def expected_positions() -> list[str]: OfficerPositionEnum.PRESIDENT, OfficerPositionEnum.VICE_PRESIDENT, OfficerPositionEnum.TREASURER, - OfficerPositionEnum.DIRECTOR_OF_RESOURCES, OfficerPositionEnum.DIRECTOR_OF_EVENTS, OfficerPositionEnum.DIRECTOR_OF_EDUCATIONAL_EVENTS, OfficerPositionEnum.ASSISTANT_DIRECTOR_OF_EVENTS, OfficerPositionEnum.DIRECTOR_OF_COMMUNICATIONS, - #OfficerPositionEnum.DIRECTOR_OF_OUTREACH, # TODO (#101): when https://github.com/CSSS/documents/pull/9/files merged + # OfficerPositionEnum.DIRECTOR_OF_OUTREACH, # TODO (#101): when https://github.com/CSSS/documents/pull/9/files merged OfficerPositionEnum.DIRECTOR_OF_MULTIMEDIA, OfficerPositionEnum.DIRECTOR_OF_ARCHIVES, OfficerPositionEnum.EXECUTIVE_AT_LARGE, # TODO (#101): expect these only during fall & spring semesters. - #OfficerPositionEnum.FIRST_YEAR_REPRESENTATIVE, - - #ElectionsOfficer, + # OfficerPositionEnum.FIRST_YEAR_REPRESENTATIVE, + # ElectionsOfficer, OfficerPositionEnum.SFSS_COUNCIL_REPRESENTATIVE, OfficerPositionEnum.FROSH_WEEK_CHAIR, - OfficerPositionEnum.SYSTEM_ADMINISTRATOR, OfficerPositionEnum.WEBMASTER, ] + _EMAIL_MAP = { OfficerPositionEnum.PRESIDENT: "csss-president-current@sfu.ca", OfficerPositionEnum.VICE_PRESIDENT: "csss-vp-current@sfu.ca", OfficerPositionEnum.TREASURER: "csss-treasurer-current@sfu.ca", - OfficerPositionEnum.DIRECTOR_OF_RESOURCES: "csss-dor-current@sfu.ca", OfficerPositionEnum.DIRECTOR_OF_EVENTS: "csss-doe-current@sfu.ca", OfficerPositionEnum.DIRECTOR_OF_EDUCATIONAL_EVENTS: "csss-doee-current@sfu.ca", OfficerPositionEnum.ASSISTANT_DIRECTOR_OF_EVENTS: "csss-adoe-current@sfu.ca", OfficerPositionEnum.DIRECTOR_OF_COMMUNICATIONS: "csss-doc-current@sfu.ca", - #OfficerPositionEnum.DIRECTOR_OF_OUTREACH, + # OfficerPositionEnum.DIRECTOR_OF_OUTREACH, OfficerPositionEnum.DIRECTOR_OF_MULTIMEDIA: "csss-domm-current@sfu.ca", OfficerPositionEnum.DIRECTOR_OF_ARCHIVES: "csss-doa-current@sfu.ca", OfficerPositionEnum.EXECUTIVE_AT_LARGE: "csss-eal-current@sfu.ca", OfficerPositionEnum.FIRST_YEAR_REPRESENTATIVE: "csss-fyr-current@sfu.ca", - OfficerPositionEnum.ELECTIONS_OFFICER: "csss-election@sfu.ca", OfficerPositionEnum.SFSS_COUNCIL_REPRESENTATIVE: "csss-councilrep@sfu.ca", OfficerPositionEnum.FROSH_WEEK_CHAIR: "csss-froshchair@sfu.ca", - OfficerPositionEnum.SYSTEM_ADMINISTRATOR: "csss-sysadmin@sfu.ca", OfficerPositionEnum.WEBMASTER: "csss-webmaster@sfu.ca", OfficerPositionEnum.SOCIAL_MEDIA_MANAGER: "N/A", @@ -137,22 +131,19 @@ def expected_positions() -> list[str]: OfficerPositionEnum.PRESIDENT: 3, OfficerPositionEnum.VICE_PRESIDENT: 3, OfficerPositionEnum.TREASURER: 3, - OfficerPositionEnum.DIRECTOR_OF_RESOURCES: 3, OfficerPositionEnum.DIRECTOR_OF_EVENTS: 3, OfficerPositionEnum.DIRECTOR_OF_EDUCATIONAL_EVENTS: 3, OfficerPositionEnum.ASSISTANT_DIRECTOR_OF_EVENTS: 3, OfficerPositionEnum.DIRECTOR_OF_COMMUNICATIONS: 3, - #OfficerPositionEnum.DIRECTOR_OF_OUTREACH: 3, + # OfficerPositionEnum.DIRECTOR_OF_OUTREACH: 3, OfficerPositionEnum.DIRECTOR_OF_MULTIMEDIA: 3, OfficerPositionEnum.DIRECTOR_OF_ARCHIVES: 3, OfficerPositionEnum.EXECUTIVE_AT_LARGE: 1, OfficerPositionEnum.FIRST_YEAR_REPRESENTATIVE: 2, - OfficerPositionEnum.ELECTIONS_OFFICER: None, OfficerPositionEnum.SFSS_COUNCIL_REPRESENTATIVE: 3, OfficerPositionEnum.FROSH_WEEK_CHAIR: None, - OfficerPositionEnum.SYSTEM_ADMINISTRATOR: None, OfficerPositionEnum.WEBMASTER: None, OfficerPositionEnum.SOCIAL_MEDIA_MANAGER: None, @@ -162,22 +153,19 @@ def expected_positions() -> list[str]: OfficerPositionEnum.PRESIDENT, OfficerPositionEnum.VICE_PRESIDENT, OfficerPositionEnum.TREASURER, - OfficerPositionEnum.DIRECTOR_OF_RESOURCES, OfficerPositionEnum.DIRECTOR_OF_EVENTS, OfficerPositionEnum.DIRECTOR_OF_EDUCATIONAL_EVENTS, OfficerPositionEnum.ASSISTANT_DIRECTOR_OF_EVENTS, OfficerPositionEnum.DIRECTOR_OF_COMMUNICATIONS, - #OfficerPositionEnum.DIRECTOR_OF_OUTREACH, + # OfficerPositionEnum.DIRECTOR_OF_OUTREACH, OfficerPositionEnum.DIRECTOR_OF_MULTIMEDIA, OfficerPositionEnum.DIRECTOR_OF_ARCHIVES, OfficerPositionEnum.EXECUTIVE_AT_LARGE, OfficerPositionEnum.FIRST_YEAR_REPRESENTATIVE, - OfficerPositionEnum.ELECTIONS_OFFICER, OfficerPositionEnum.SFSS_COUNCIL_REPRESENTATIVE, OfficerPositionEnum.FROSH_WEEK_CHAIR, - OfficerPositionEnum.SYSTEM_ADMINISTRATOR, OfficerPositionEnum.WEBMASTER, OfficerPositionEnum.SOCIAL_MEDIA_MANAGER, @@ -187,13 +175,12 @@ def expected_positions() -> list[str]: OfficerPositionEnum.PRESIDENT, OfficerPositionEnum.VICE_PRESIDENT, OfficerPositionEnum.TREASURER, - OfficerPositionEnum.DIRECTOR_OF_RESOURCES, OfficerPositionEnum.DIRECTOR_OF_EVENTS, OfficerPositionEnum.DIRECTOR_OF_EDUCATIONAL_EVENTS, OfficerPositionEnum.ASSISTANT_DIRECTOR_OF_EVENTS, OfficerPositionEnum.DIRECTOR_OF_COMMUNICATIONS, - #OfficerPositionEnum.DIRECTOR_OF_OUTREACH, + # OfficerPositionEnum.DIRECTOR_OF_OUTREACH, OfficerPositionEnum.DIRECTOR_OF_MULTIMEDIA, OfficerPositionEnum.DIRECTOR_OF_ARCHIVES, ] diff --git a/src/officers/crud.py b/src/officers/crud.py index d9fd3323..d5a00730 100644 --- a/src/officers/crud.py +++ b/src/officers/crud.py @@ -1,158 +1,200 @@ -from collections.abc import Sequence -from datetime import date, datetime +from datetime import date -import sqlalchemy from fastapi import HTTPException +from sqlalchemy import delete, select, update from sqlalchemy.ext.asyncio import AsyncSession import auth.crud -import auth.tables import database import utils +from auth.tables import SiteUserDB from data import semesters from officers.constants import OfficerPosition -from officers.models import OfficerInfoResponse, OfficerTermCreate -from officers.tables import OfficerInfo, OfficerTerm +from officers.models import OfficerCreate, OfficerPrivate, OfficerPublic +from officers.tables import OfficerInfoDB, OfficerTermDB # NOTE: this module should not do any data validation; that should be done in the urls.py or higher layer + async def current_officers( - db_session: database.DBSession, -) -> list[OfficerInfoResponse]: + db_session: database.DBSession, include_private: bool = False +) -> list[OfficerPrivate] | list[OfficerPublic]: """ Get info about officers that are active. Go through all active & complete officer terms. Returns a mapping between officer position and officer terms """ curr_time = date.today() - query = (sqlalchemy.select(OfficerTerm, OfficerInfo) - .join(OfficerInfo, OfficerTerm.computing_id == OfficerInfo.computing_id) - .where((OfficerTerm.start_date <= curr_time) & (OfficerTerm.end_date >= curr_time)) - .order_by(OfficerTerm.start_date.desc()) - ) + query = ( + select(OfficerTermDB, OfficerInfoDB) + .join(OfficerInfoDB, OfficerTermDB.computing_id == OfficerInfoDB.computing_id) + .where((OfficerTermDB.start_date <= curr_time) & (OfficerTermDB.end_date >= curr_time)) + .order_by(OfficerTermDB.start_date.desc()) + ) - result: Sequence[sqlalchemy.Row[tuple[OfficerTerm, OfficerInfo]]] = (await db_session.execute(query)).all() + result = (await db_session.execute(query)).all() officer_list = [] - for term, officer in result: - officer_list.append(OfficerInfoResponse( - legal_name = officer.legal_name, - is_active = True, - position = term.position, - start_date = term.start_date, - end_date = term.end_date, - biography = term.biography, - csss_email = OfficerPosition.to_email(term.position), - - discord_id = officer.discord_id, - discord_name = officer.discord_name, - discord_nickname = officer.discord_nickname, - computing_id = officer.computing_id, - phone_number = officer.phone_number, - github_username = officer.github_username, - google_drive_email = officer.google_drive_email, - photo_url = term.photo_url - )) + if include_private: + for term, officer in result: + officer_list.append( + OfficerPrivate( + legal_name=officer.legal_name, + is_active=True, + position=term.position, + start_date=term.start_date, + end_date=term.end_date, + biography=term.biography, + csss_email=OfficerPosition.to_email(term.position), + discord_id=officer.discord_id, + discord_name=officer.discord_name, + discord_nickname=officer.discord_nickname, + computing_id=officer.computing_id, + phone_number=officer.phone_number, + github_username=officer.github_username, + google_drive_email=officer.google_drive_email, + photo_url=term.photo_url, + ) + ) + else: + for term, officer in result: + officer_list.append( + OfficerPublic( + legal_name=officer.legal_name, + is_active=True, + position=term.position, + start_date=term.start_date, + end_date=term.end_date, + biography=term.biography, + csss_email=OfficerPosition.to_email(term.position), + ) + ) return officer_list -async def all_officers( - db_session: AsyncSession, - include_future_terms: bool -) -> list[OfficerInfoResponse]: + +async def get_current_terms_by_position(db_session: database.DBSession, position: str) -> list[OfficerTermDB]: """ - This could be a lot of data, so be careful + Get current officer that holds a position """ - # NOTE: paginate data if needed - query = (sqlalchemy.select(OfficerTerm, OfficerInfo) - .join(OfficerInfo, OfficerTerm.computing_id == OfficerInfo.computing_id) - .order_by(OfficerTerm.start_date.desc()) - ) + curr_time = date.today() + query = ( + select(OfficerTermDB) + .where( + (OfficerTermDB.start_date <= curr_time) & (OfficerTermDB.end_date >= curr_time) & OfficerTermDB.position + == position + ) + .order_by(OfficerTermDB.start_date.desc()) + ) + + result = list((await db_session.scalars(query)).all()) + return result + + +async def get_all_officers( + db_session: AsyncSession, include_future_terms: bool, include_private: bool +) -> list[OfficerPrivate] | list[OfficerPublic]: + """ + This could be a lot of data, so be careful + """ + query = ( + select(OfficerTermDB, OfficerInfoDB) + .join(OfficerInfoDB, OfficerTermDB.computing_id == OfficerInfoDB.computing_id) + .order_by(OfficerTermDB.start_date.desc()) + ) if not include_future_terms: query = utils.has_started_term(query) - result: Sequence[sqlalchemy.Row[tuple[OfficerTerm, OfficerInfo]]] = (await db_session.execute(query)).all() officer_list = [] - for term, officer in result: - officer_list.append(OfficerInfoResponse( - legal_name = officer.legal_name, - is_active = utils.is_active_term(term), - position = term.position, - start_date = term.start_date, - end_date = term.end_date, - biography = term.biography, - csss_email = OfficerPosition.to_email(term.position), - - discord_id = officer.discord_id, - discord_name = officer.discord_name, - discord_nickname = officer.discord_nickname, - computing_id = officer.computing_id, - phone_number = officer.phone_number, - github_username = officer.github_username, - google_drive_email = officer.google_drive_email, - photo_url = term.photo_url - )) + # NOTE: paginate data if needed + result = (await db_session.execute(query)).all() + + if include_private: + for term, officer in result: + officer_list.append( + OfficerPrivate( + legal_name=officer.legal_name, + is_active=utils.is_active_term(term), + position=term.position, + start_date=term.start_date, + end_date=term.end_date, + biography=term.biography, + csss_email=OfficerPosition.to_email(term.position), + discord_id=officer.discord_id, + discord_name=officer.discord_name, + discord_nickname=officer.discord_nickname, + computing_id=officer.computing_id, + phone_number=officer.phone_number, + github_username=officer.github_username, + google_drive_email=officer.google_drive_email, + photo_url=term.photo_url, + ) + ) + else: + for term, officer in result: + officer_list.append( + OfficerPublic( + legal_name=officer.legal_name, + is_active=utils.is_active_term(term), + position=term.position, + start_date=term.start_date, + end_date=term.end_date, + biography=term.biography, + csss_email=OfficerPosition.to_email(term.position), + ) + ) return officer_list -async def get_officer_info_or_raise(db_session: database.DBSession, computing_id: str) -> OfficerInfo: - officer_term = await db_session.scalar( - sqlalchemy - .select(OfficerInfo) - .where(OfficerInfo.computing_id == computing_id) - ) + +async def get_officer_info_or_raise(db_session: database.DBSession, computing_id: str) -> OfficerInfoDB: + officer_term = await db_session.scalar(select(OfficerInfoDB).where(OfficerInfoDB.computing_id == computing_id)) if officer_term is None: raise HTTPException(status_code=404, detail=f"officer_info for computing_id={computing_id} does not exist yet") return officer_term -async def get_new_officer_info_or_raise(db_session: database.DBSession, computing_id: str) -> OfficerInfo: + +async def get_new_officer_info_or_raise(db_session: database.DBSession, computing_id: str) -> OfficerInfoDB: """ This check is for after a create/update """ - officer_term = await db_session.scalar( - sqlalchemy - .select(OfficerInfo) - .where(OfficerInfo.computing_id == computing_id) - ) + officer_term = await db_session.scalar(select(OfficerInfoDB).where(OfficerInfoDB.computing_id == computing_id)) if officer_term is None: raise HTTPException(status_code=500, detail=f"failed to fetch {computing_id} after update") return officer_term + async def get_officer_terms( db_session: database.DBSession, computing_id: str, include_future_terms: bool, -) -> list[OfficerTerm]: +) -> list[OfficerTermDB]: query = ( - sqlalchemy - .select(OfficerTerm) - .where(OfficerTerm.computing_id == computing_id) + select(OfficerTermDB) + .where(OfficerTermDB.computing_id == computing_id) # In order of most recent start date first - .order_by(OfficerTerm.start_date.desc()) + .order_by(OfficerTermDB.start_date.desc()) ) if not include_future_terms: query = utils.has_started_term(query) - return (await db_session.scalars(query)).all() + return list((await db_session.scalars(query)).all()) -async def get_active_officer_terms( - db_session: database.DBSession, - computing_id: str -) -> list[OfficerTerm]: + +async def get_active_officer_terms(db_session: database.DBSession, computing_id: str) -> list[OfficerTermDB]: """ Returns the list of active officer terms for a user. Returns [] if the user is not currently an officer. An officer can have multiple positions at once, such as Webmaster, Frosh chair, and DoEE. """ query = ( - sqlalchemy - .select(OfficerTerm) - .where(OfficerTerm.computing_id == computing_id) + select(OfficerTermDB) + .where(OfficerTermDB.computing_id == computing_id) # In order of most recent start date first - .order_by(OfficerTerm.start_date.desc()) + .order_by(OfficerTermDB.start_date.desc()) ) query = utils.is_active_officer(query) - officer_term_list = (await db_session.scalars(query)).all() - return officer_term_list + return list((await db_session.scalars(query)).all()) + async def current_officer_positions(db_session: database.DBSession, computing_id: str) -> list[str]: """ @@ -161,12 +203,11 @@ async def current_officer_positions(db_session: database.DBSession, computing_id officer_term_list = await get_active_officer_terms(db_session, computing_id) return [term.position for term in officer_term_list] -async def get_officer_term_by_id_or_raise(db_session: database.DBSession, term_id: int, is_new: bool = False) -> OfficerTerm: - officer_term = await db_session.scalar( - sqlalchemy - .select(OfficerTerm) - .where(OfficerTerm.id == term_id) - ) + +async def get_officer_term_by_id_or_raise( + db_session: database.DBSession, term_id: int, is_new: bool = False +) -> OfficerTermDB: + officer_term = await db_session.scalar(select(OfficerTermDB).where(OfficerTermDB.id == term_id)) if officer_term is None: if is_new: raise HTTPException(status_code=500, detail=f"could not find new officer_term with id={term_id}") @@ -174,23 +215,17 @@ async def get_officer_term_by_id_or_raise(db_session: database.DBSession, term_i raise HTTPException(status_code=404, detail=f"could not find officer_term with id={term_id}") return officer_term -async def create_new_officer_info( - db_session: database.DBSession, - new_officer_info: OfficerInfo -) -> bool: + +async def create_new_officer_info(db_session: database.DBSession, new_officer_info: OfficerInfoDB) -> bool: """Return False if the officer already exists & don't do anything.""" if not await auth.crud.site_user_exists(db_session, new_officer_info.computing_id): # if computing_id has not been created as a site_user yet, add them - db_session.add(auth.tables.SiteUser( - computing_id=new_officer_info.computing_id, - first_logged_in=None, - last_logged_in=None - )) + db_session.add( + SiteUserDB(computing_id=new_officer_info.computing_id, first_logged_in=None, last_logged_in=None) + ) existing_officer_info = await db_session.scalar( - sqlalchemy - .select(OfficerInfo) - .where(OfficerInfo.computing_id == new_officer_info.computing_id) + select(OfficerInfoDB).where(OfficerInfoDB.computing_id == new_officer_info.computing_id) ) if existing_officer_info is not None: return False @@ -198,10 +233,8 @@ async def create_new_officer_info( db_session.add(new_officer_info) return True -async def create_new_officer_term( - db_session: database.DBSession, - new_officer_term: OfficerTerm -): + +async def create_new_officer_term(db_session: database.DBSession, new_officer_term: OfficerTermDB): position_length = OfficerPosition.length_in_semesters(new_officer_term.position) if position_length is not None: # when creating a new position, assign a default end date if one exists @@ -211,17 +244,100 @@ async def create_new_officer_term( ) db_session.add(new_officer_term) -async def update_officer_info( - db_session: database.DBSession, - new_officer_info: OfficerInfo -) -> bool: + +async def create_multiple_officers(db_session: database.DBSession, new_officers: list[OfficerCreate]): + computing_ids = {term.computing_id for term in new_officers} + + # Prepare new officer info + # If it's someone's first time being added as an officer, we need to create their Officer Info entry first + existing_officer_infos = set( + ( + await db_session.scalars( + select(OfficerInfoDB.computing_id).where(OfficerInfoDB.computing_id.in_(computing_ids)) + ) + ).all() + ) + + new_officer_infos = [] + seen_computing_ids: set[str] = set() # Just in case duplicate slips through + for off in new_officers: + if off.computing_id not in existing_officer_infos and off.computing_id not in seen_computing_ids: + new_officer_infos.append( + OfficerInfoDB( + computing_id=off.computing_id, + legal_name=off.legal_name, + phone_number=off.phone_number, + discord_id=off.discord_id, + discord_name=off.discord_name, + discord_nickname=off.discord_nickname, + google_drive_email=off.google_drive_email, + github_username=off.github_username, + ) + ) + seen_computing_ids.add(off.computing_id) + + # Get existing site users and create ones that are missing + existing_site_users = set( + ( + await db_session.scalars(select(SiteUserDB.computing_id).where(SiteUserDB.computing_id.in_(computing_ids))) + ).all() + ) + new_site_users = [ + SiteUserDB(computing_id=cid, first_logged_in=None, last_logged_in=None) + for cid in computing_ids + if cid not in existing_site_users + ] + + # Prepare officer terms with computed end dates + new_officer_terms: list[OfficerTermDB] = [] + for off in new_officers: + end_date = off.end_date + if end_date is None: + position_length = OfficerPosition.length_in_semesters(off.position) + if position_length is not None: + end_date = semesters.step_semesters( + semesters.current_semester_start(off.start_date), + position_length, + ) + new_officer_terms.append( + OfficerTermDB( + computing_id=off.computing_id, + position=off.position, + start_date=off.start_date, + end_date=end_date, + nickname=off.nickname, + favourite_course_0=off.favourite_course_0, + favourite_course_1=off.favourite_course_1, + favourite_pl_0=off.favourite_pl_0, + favourite_pl_1=off.favourite_pl_1, + biography=off.biography, + photo_url=off.photo_url, + ) + ) + + # Create all the new entries + if new_site_users: + db_session.add_all(new_site_users) + if new_officer_infos: + db_session.add_all(new_officer_infos) + db_session.add_all(new_officer_terms) + + # Flush gets the generated IDs, but does not commit + await db_session.flush() + + # Refresh each term to ensure all attributes are loaded for Pydantic validation + for term in new_officer_terms: + await db_session.refresh(term) + + return new_officer_terms + + +async def update_officer_info(db_session: database.DBSession, new_officer_info: OfficerInfoDB) -> bool: """ Return False if the officer doesn't exist yet """ officer_info = await db_session.scalar( - sqlalchemy - .select(OfficerInfo) - .where(OfficerInfo.computing_id == new_officer_info.computing_id) + select(OfficerInfoDB).where(OfficerInfoDB.computing_id == new_officer_info.computing_id) ) if officer_info is None: return False @@ -229,40 +345,30 @@ async def update_officer_info( # NOTE: if there's ever an insert entry error, it will raise SQLAlchemyError # see: https://stackoverflow.com/questions/2136739/how-to-check-and-handle-errors-in-sqlalchemy await db_session.execute( - sqlalchemy - .update(OfficerInfo) - .where(OfficerInfo.computing_id == officer_info.computing_id) + update(OfficerInfoDB) + .where(OfficerInfoDB.computing_id == officer_info.computing_id) .values(new_officer_info.to_update_dict()) ) return True + async def update_officer_term( db_session: database.DBSession, - new_officer_term: OfficerTerm, + new_officer_term: OfficerTermDB, ) -> bool: """ Update all officer term data in `new_officer_term` based on the term id. Returns false if the above entry does not exist. """ - officer_term = await db_session.scalar( - sqlalchemy - .select(OfficerTerm) - .where(OfficerTerm.id == new_officer_term.id) - ) + officer_term = await db_session.scalar(select(OfficerTermDB).where(OfficerTermDB.id == new_officer_term.id)) if officer_term is None: return False await db_session.execute( - sqlalchemy - .update(OfficerTerm) - .where(OfficerTerm.id == new_officer_term.id) - .values(new_officer_term.to_update_dict()) + update(OfficerTermDB).where(OfficerTermDB.id == new_officer_term.id).values(new_officer_term.to_update_dict()) ) return True + async def delete_officer_term_by_id(db_session: database.DBSession, term_id: int): - await db_session.execute( - sqlalchemy - .delete(OfficerTerm) - .where(OfficerTerm.id == term_id) - ) + await db_session.execute(delete(OfficerTermDB).where(OfficerTermDB.id == term_id)) diff --git a/src/officers/models.py b/src/officers/models.py index 67c1b2c4..9532929e 100644 --- a/src/officers/models.py +++ b/src/officers/models.py @@ -2,6 +2,7 @@ from pydantic import BaseModel, ConfigDict, Field +from constants import COMPUTING_ID_LEN from officers.constants import OFFICER_LEGAL_NAME_MAX, OfficerPositionEnum OFFICER_PRIVATE_INFO = { @@ -12,39 +13,124 @@ "phone_number", "github_username", "google_drive_email", - "photo_url" + "photo_url", } -class OfficerInfoBaseModel(BaseModel): + +# Officer Info Models +class OfficerInfo(BaseModel): + model_config = ConfigDict(from_attributes=True) + computing_id: str = Field(..., max_length=COMPUTING_ID_LEN) + legal_name: str = Field(..., max_length=OFFICER_LEGAL_NAME_MAX) + phone_number: str | None = None + discord_id: str | None = None + discord_name: str | None = None + discord_nickname: str | None = None + google_drive_email: str | None = None + github_username: str | None = None + + +# Officer Term Models +class OfficerTermCreate(BaseModel): + """Request body to create a new Officer Term""" + + computing_id: str = Field(..., max_length=COMPUTING_ID_LEN) + position: str = Field(..., max_length=128) + start_date: date + end_date: date | None = None + nickname: str | None = Field(None, max_length=128) + favourite_course_0: str | None = Field(None, max_length=64) + favourite_course_1: str | None = Field(None, max_length=64) + favourite_pl_0: str | None = Field(None, max_length=64) + favourite_pl_1: str | None = Field(None, max_length=64) + biography: str | None = None + photo_url: str | None = None + + +class OfficerTerm(OfficerTermCreate): + """Response model for OfficerTerm""" + + model_config = ConfigDict(from_attributes=True) + + id: int + + +class OfficerTermUpdate(BaseModel): + """Request body to patch an Officer Term""" + + computing_id: str | None = Field(None, max_length=COMPUTING_ID_LEN) + position: str | None = Field(None, max_length=128) + start_date: date | None = None + end_date: date | None = None + nickname: str | None = Field(None, max_length=128) + favourite_course_0: str | None = Field(None, max_length=64) + favourite_course_1: str | None = Field(None, max_length=64) + favourite_pl_0: str | None = Field(None, max_length=64) + favourite_pl_1: str | None = Field(None, max_length=64) + biography: str | None = None + photo_url: str | None = None + + +# Concatenated Officer Models +class OfficerBase(BaseModel): # TODO (#71): compute this using SFU's API & remove from being uploaded legal_name: str = Field(..., max_length=OFFICER_LEGAL_NAME_MAX) position: OfficerPositionEnum start_date: date end_date: date | None = None + nickname: str | None = None + biography: str | None = None + csss_email: str | None = None + -class OfficerInfoResponse(OfficerInfoBaseModel): +class OfficerPublic(OfficerBase): """ Response when fetching public officer data """ + is_active: bool - nickname: str | None = None - biography: str | None = None - csss_email: str | None = None - # Private data + +class OfficerPrivate(OfficerPublic): + """ + Response when fetching private officer data + """ + discord_id: str | None = None discord_name: str | None = None discord_nickname: str | None = None - computing_id: str | None = None + computing_id: str phone_number: str | None = None github_username: str | None = None google_drive_email: str | None = None photo_url: str | None = None + +class OfficerCreate(OfficerBase): + """ + Parameters when creating a new Officer + """ + + computing_id: str + + discord_id: str | None = None + discord_name: str | None = None + discord_nickname: str | None = None + phone_number: str | None = None + github_username: str | None = None + google_drive_email: str | None = None + photo_url: str | None = None + favourite_course_0: str | None = Field(None, max_length=64) + favourite_course_1: str | None = Field(None, max_length=64) + favourite_pl_0: str | None = Field(None, max_length=64) + favourite_pl_1: str | None = Field(None, max_length=64) + + class OfficerSelfUpdate(BaseModel): """ Used when an Officer is updating their own information """ + nickname: str | None = None discord_id: str | None = None discord_name: str | None = None @@ -54,47 +140,13 @@ class OfficerSelfUpdate(BaseModel): github_username: str | None = None google_drive_email: str | None = None + class OfficerUpdate(OfficerSelfUpdate): """ Used when an admin is updating an Officer's info """ - legal_name: str | None = Field(None, max_length=OFFICER_LEGAL_NAME_MAX) - position: OfficerPositionEnum | None = None - start_date: date | None = None - end_date: date | None = None - -class OfficerTermBaseModel(BaseModel): - computing_id: str - position: OfficerPositionEnum - start_date: date - -class OfficerTermCreate(OfficerTermBaseModel): - """ - Params to create a new Officer Term - """ - legal_name: str - -class OfficerTermResponse(OfficerTermCreate): - id: int - end_date: date | None = None - favourite_course_0: str | None = None - favourite_course_1: str | None = None - favourite_pl_0: str | None = None - favourite_pl_1: str | None = None - biography: str | None = None - photo_url: str | None = None - -class OfficerTermUpdate(BaseModel): - nickname: str | None = None - favourite_course_0: str | None = None - favourite_course_1: str | None = None - favourite_pl_0: str | None = None - favourite_pl_1: str | None = None - biography: str | None = None - - # Admin only + legal_name: str | None = Field(None, max_length=OFFICER_LEGAL_NAME_MAX) position: OfficerPositionEnum | None = None start_date: date | None = None end_date: date | None = None - photo_url: str | None = None # Block this, just in case diff --git a/src/officers/tables.py b/src/officers/tables.py index 15567eba..6ee48d94 100644 --- a/src/officers/tables.py +++ b/src/officers/tables.py @@ -1,6 +1,7 @@ from __future__ import annotations -from datetime import date, datetime +from datetime import date +from typing import Any from sqlalchemy import ( Date, @@ -21,12 +22,12 @@ ) from database import Base from officers.constants import OFFICER_LEGAL_NAME_MAX, OFFICER_POSITION_MAX, OfficerPositionEnum -from officers.models import OfficerSelfUpdate, OfficerTermUpdate, OfficerUpdate +from officers.models import OfficerInfo, OfficerTerm, OfficerTermUpdate, OfficerUpdate # A row represents an assignment of a person to a position. # An officer with multiple positions, such as Frosh Chair & DoE, is broken up into multiple assignments. -class OfficerTerm(Base): +class OfficerTermDB(Base): __tablename__ = "officer_term" id: Mapped[str] = mapped_column(Integer, primary_key=True, autoincrement=True) @@ -49,37 +50,23 @@ class OfficerTerm(Base): favourite_pl_0: Mapped[str] = mapped_column(String(64), nullable=True) favourite_pl_1: Mapped[str] = mapped_column(String(64), nullable=True) biography: Mapped[str] = mapped_column(Text, nullable=True) - photo_url: Mapped[str] = mapped_column(Text, nullable=True) # some urls get big, best to let it be a string + photo_url: Mapped[str] = mapped_column(Text, nullable=True) # some urls get big, best to let it be a string - __table_args__ = (UniqueConstraint("computing_id", "position", "start_date"),) # This needs a comma to work + __table_args__ = (UniqueConstraint("computing_id", "position", "start_date"),) # This needs a comma to work def serializable_dict(self) -> dict: - return { - "id": self.id, - "computing_id": self.computing_id, - - "position": self.position, - "start_date": self.start_date.isoformat() if self.start_date is not None else None, - "end_date": self.end_date.isoformat() if self.end_date is not None else None, - - "nickname": self.nickname, - "favourite_course_0": self.favourite_course_0, - "favourite_course_1": self.favourite_course_1, - "favourite_pl_0": self.favourite_pl_0, - "favourite_pl_1": self.favourite_pl_1, - "biography": self.biography, - "photo_url": self.photo_url, - } + return OfficerTerm.model_validate(self).model_dump(mode="json") def update_from_params(self, params: OfficerTermUpdate, admin_update: bool = True): if admin_update: update_data = params.model_dump(exclude_unset=True) else: - update_data = params.model_dump(exclude_unset=True, exclude={"position", "start_date", "end_date", "photo_url"}) + update_data = params.model_dump( + exclude_unset=True, exclude={"position", "start_date", "end_date", "photo_url"} + ) for k, v in update_data.items(): setattr(self, k, v) - def is_filled_in(self): return ( # photo & end_date don't have to be uploaded for the term to be "filled" @@ -97,11 +84,9 @@ def is_filled_in(self): def to_update_dict(self) -> dict: return { "computing_id": self.computing_id, - "position": self.position, "start_date": self.start_date, "end_date": self.end_date, - "nickname": self.nickname, "favourite_course_0": self.favourite_course_0, "favourite_course_1": self.favourite_course_1, @@ -111,9 +96,10 @@ def to_update_dict(self) -> dict: "photo_url": self.photo_url, } + # this table contains information that we only need a most up-to-date version of, and # don't need to keep a history of. However, it also can't be easily updated. -class OfficerInfo(Base): +class OfficerInfoDB(Base): __tablename__ = "officer_info" computing_id: Mapped[str] = mapped_column( @@ -123,7 +109,9 @@ class OfficerInfo(Base): ) # TODO (#71): we'll need to use SFU's API to get the legal name for users - legal_name: Mapped[str] = mapped_column(String(OFFICER_LEGAL_NAME_MAX), nullable=False) # some people have long names, you never know + legal_name: Mapped[str] = mapped_column( + String(OFFICER_LEGAL_NAME_MAX), nullable=False + ) # some people have long names, you never know phone_number: Mapped[str] = mapped_column(String(24), nullable=True) # TODO (#99): add unique constraints to discord_id (stops users from stealing the username of someone else) @@ -143,23 +131,10 @@ class OfficerInfo(Base): # TODO (#22): add support for giving executives bitwarden access automagically # has_signed_into_bitwarden: Mapped[str] = mapped_column(Boolean) - def serializable_dict(self) -> dict: - return { - "is_filled_in": self.is_filled_in(), - - "legal_name": self.legal_name, - "discord_id": self.discord_id, - "discord_name": self.discord_name, - "discord_nickname": self.discord_nickname, - - "computing_id": self.computing_id, - "phone_number": self.phone_number, - "github_username": self.github_username, - - "google_drive_email": self.google_drive_email, - } + def serializable_dict(self) -> dict[str, Any]: + return OfficerInfo.model_validate(self).model_dump(mode="json") - def update_from_params(self, params: OfficerUpdate | OfficerSelfUpdate): + def update_from_params(self, params: OfficerUpdate | OfficerUpdate): update_data = params.model_dump(exclude_unset=True) for k, v in update_data.items(): setattr(self, k, v) @@ -181,11 +156,9 @@ def to_update_dict(self) -> dict: # TODO (#71): if the API call to SFU's api to get legal name fails, we want to fail & not insert the entry. # for now, we should insert a default value "legal_name": "default name" if self.legal_name is None else self.legal_name, - "discord_id": self.discord_id, "discord_name": self.discord_name, "discord_nickname": self.discord_nickname, - "phone_number": self.phone_number, "github_username": self.github_username, "google_drive_email": self.google_drive_email, diff --git a/src/officers/types.py b/src/officers/types.py index 99664f2d..f441f88c 100644 --- a/src/officers/types.py +++ b/src/officers/types.py @@ -10,7 +10,7 @@ from constants import COMPUTING_ID_MAX from discord import discord from officers.constants import OfficerPosition -from officers.tables import OfficerInfo, OfficerTerm +from officers.tables import OfficerInfoDB, OfficerTermDB @dataclass @@ -27,6 +27,7 @@ def valid_or_raise(self): elif self.position not in OfficerPosition.position_list(): raise HTTPException(status_code=400, detail=f"invalid position={self.position}") + @dataclass class OfficerInfoUpload: # TODO (#71): compute this using SFU's API & remove from being uploaded @@ -41,21 +42,19 @@ def valid_or_raise(self): if self.legal_name is None or self.legal_name == "": raise HTTPException(status_code=400, detail="legal name must not be empty") - def to_officer_info(self, computing_id: str, discord_id: str | None, discord_nickname: str | None) -> OfficerInfo: - return OfficerInfo( - computing_id = computing_id, - legal_name = self.legal_name, - - discord_id = discord_id, - discord_name = self.discord_name, - discord_nickname = discord_nickname, - - phone_number = self.phone_number, - github_username = self.github_username, - google_drive_email = self.google_drive_email, + def to_officer_info(self, computing_id: str, discord_id: str | None, discord_nickname: str | None) -> OfficerInfoDB: + return OfficerInfoDB( + computing_id=computing_id, + legal_name=self.legal_name, + discord_id=discord_id, + discord_name=self.discord_name, + discord_nickname=discord_nickname, + phone_number=self.phone_number, + github_username=self.github_username, + google_drive_email=self.google_drive_email, ) - async def validate(self, computing_id: str, old_officer_info: OfficerInfo) -> tuple[list[str], OfficerInfo]: + async def validate(self, computing_id: str, old_officer_info: OfficerInfoDB) -> tuple[list[str], OfficerInfoDB]: """ Validate that the uploaded officer info is correct; if it's not, revert it to old_officer_info. """ @@ -123,6 +122,7 @@ async def validate(self, computing_id: str, old_officer_info: OfficerInfo) -> tu return validation_failures, corrected_officer_info + @dataclass class OfficerTermUpload: # only admins can change: @@ -148,26 +148,26 @@ def valid_or_raise(self): elif self.end_date is not None and self.start_date > self.end_date: raise HTTPException(status_code=400, detail="end_date must be after start_date") - def to_officer_term(self, term_id: str) -> OfficerTerm: - return OfficerTerm( - id = term_id, - - computing_id = self.computing_id, - position = self.position, - start_date = self.start_date, - end_date = self.end_date, - - nickname = self.nickname, - favourite_course_0 = self.favourite_course_0, - favourite_course_1 = self.favourite_course_1, - favourite_pl_0 = self.favourite_pl_0, - favourite_pl_1 = self.favourite_pl_1, - biography = self.biography, - photo_url = self.photo_url, + def to_officer_term(self, term_id: str) -> OfficerTermDB: + return OfficerTermDB( + id=term_id, + computing_id=self.computing_id, + position=self.position, + start_date=self.start_date, + end_date=self.end_date, + nickname=self.nickname, + favourite_course_0=self.favourite_course_0, + favourite_course_1=self.favourite_course_1, + favourite_pl_0=self.favourite_pl_0, + favourite_pl_1=self.favourite_pl_1, + biography=self.biography, + photo_url=self.photo_url, ) + # -------------------------------------------- # + @dataclass class OfficerPrivateData: computing_id: str | None @@ -175,6 +175,7 @@ class OfficerPrivateData: github_username: str | None google_drive_email: str | None + @dataclass class OfficerData: is_active: bool @@ -210,36 +211,33 @@ def serializable_dict(self): @staticmethod def from_data( - term: OfficerTerm, - officer_info: OfficerInfo, + term: OfficerTermDB, + officer_info: OfficerInfoDB, include_private_data: bool, is_active: bool, ) -> OfficerData: return OfficerData( - is_active = is_active, - - position = term.position, - start_date = term.start_date, - end_date = term.end_date, - - legal_name = officer_info.legal_name, - nickname = term.nickname, - discord_name = officer_info.discord_name, - discord_nickname = officer_info.discord_nickname, - - favourite_course_0 = term.favourite_course_0, - favourite_course_1 = term.favourite_course_1, - favourite_language_0 = term.favourite_pl_0, - favourite_language_1 = term.favourite_pl_1, - - csss_email = OfficerPosition.to_email(term.position), - biography = term.biography, - photo_url = term.photo_url, - - private_data = OfficerPrivateData( - computing_id = term.computing_id, - phone_number = officer_info.phone_number, - github_username = officer_info.github_username, - google_drive_email = officer_info.google_drive_email, - ) if include_private_data else None, + is_active=is_active, + position=term.position, + start_date=term.start_date, + end_date=term.end_date, + legal_name=officer_info.legal_name, + nickname=term.nickname, + discord_name=officer_info.discord_name, + discord_nickname=officer_info.discord_nickname, + favourite_course_0=term.favourite_course_0, + favourite_course_1=term.favourite_course_1, + favourite_language_0=term.favourite_pl_0, + favourite_language_1=term.favourite_pl_1, + csss_email=OfficerPosition.to_email(term.position), + biography=term.biography, + photo_url=term.photo_url, + private_data=OfficerPrivateData( + computing_id=term.computing_id, + phone_number=officer_info.phone_number, + github_username=officer_info.github_username, + google_drive_email=officer_info.google_drive_email, + ) + if include_private_data + else None, ) diff --git a/src/officers/urls.py b/src/officers/urls.py index b74e8396..c9e924a1 100755 --- a/src/officers/urls.py +++ b/src/officers/urls.py @@ -1,24 +1,24 @@ -from fastapi import APIRouter, HTTPException, Request +from fastapi import APIRouter, Depends, HTTPException, Request, status from fastapi.responses import JSONResponse import auth.crud import database import officers.crud import utils +from dependencies import LoggedInUser, SessionUser, perm_admin from officers.constants import OfficerPositionEnum from officers.models import ( - OFFICER_PRIVATE_INFO, - OfficerInfoResponse, - OfficerSelfUpdate, - OfficerTermCreate, - OfficerTermResponse, + OfficerCreate, + OfficerInfo, + OfficerPrivate, + OfficerPublic, + OfficerTerm, OfficerTermUpdate, OfficerUpdate, ) -from officers.tables import OfficerInfo, OfficerTerm -from permission.types import OfficerPrivateInfo, WebsiteAdmin +from permission.types import OfficerPrivateInfo +from utils.permissions import is_user_website_admin, verify_update from utils.shared_models import DetailModel, SuccessResponse -from utils.urls import admin_or_raise, is_website_admin, logged_in_or_raise router = APIRouter( prefix="/officers", @@ -28,10 +28,13 @@ # ---------------------------------------- # # checks + async def _has_officer_private_info_access( - request: Request, - db_session: database.DBSession -) -> tuple[bool, str | None,]: + request: Request, db_session: database.DBSession +) -> tuple[ + bool, + str | None, +]: """determine if the user has access to private officer info""" session_id = request.cookies.get("session_id", None) if session_id is None: @@ -41,17 +44,20 @@ async def _has_officer_private_info_access( if computing_id is None: return False, None + # TODO: Fix this permission has_private_access = await OfficerPrivateInfo.has_permission(db_session, computing_id) return has_private_access, computing_id + # ---------------------------------------- # # endpoints + @router.get( "/current", description="Get information about the current officers. With no authorization, only get basic info.", - response_model=dict[OfficerPositionEnum, OfficerInfoResponse], - operation_id="get_current_officers" + response_model=dict[OfficerPositionEnum, OfficerPublic], + operation_id="get_current_officers", ) async def current_officers( request: Request, @@ -59,23 +65,21 @@ async def current_officers( ): has_private_access, _ = await _has_officer_private_info_access(request, db_session) - curr_officers = await officers.crud.current_officers(db_session) - exclude = OFFICER_PRIVATE_INFO if not has_private_access else {} + curr_officers = await officers.crud.current_officers(db_session, has_private_access) res = {} for officer in curr_officers: - res[officer.position] = officer.model_dump(exclude=exclude, mode="json") + res[officer.position] = officer.model_dump(mode="json") return JSONResponse(res) + @router.get( "/all", description="Information for all execs from all exec terms", - response_model=list[OfficerInfoResponse], - responses={ - 403: { "description": "not authorized to view private info", "model": DetailModel } - }, - operation_id="get_all_officers" + response_model=list[OfficerPrivate] | list[OfficerPublic], + responses={403: {"description": "not authorized", "model": DetailModel}}, + operation_id="get_all_officers", ) async def all_officers( request: Request, @@ -85,18 +89,13 @@ async def all_officers( include_future_terms: bool = False, ): has_private_access, computing_id = await _has_officer_private_info_access(request, db_session) - if include_future_terms: - is_website_admin = (computing_id is not None) and (await WebsiteAdmin.has_permission(db_session, computing_id)) - if not is_website_admin: - raise HTTPException(status_code=401, detail="only website admins can view all executive terms that have not started yet") + if include_future_terms and (computing_id is None or not (await is_user_website_admin(computing_id, db_session))): + raise HTTPException(status_code=401, detail="not authorized") + + all_officers = await officers.crud.get_all_officers(db_session, include_future_terms, has_private_access) - all_officers = await officers.crud.all_officers(db_session, include_future_terms) - exclude = OFFICER_PRIVATE_INFO if not has_private_access else {} + return JSONResponse([officer_data.model_dump(mode="json") for officer_data in all_officers]) - return JSONResponse(content=[ - officer_data.model_dump(exclude=exclude, mode="json") - for officer_data in all_officers - ]) @router.get( "/terms/{computing_id}", @@ -104,102 +103,67 @@ async def all_officers( Get term info for an executive. All term info is public for all past or active terms. Future terms can only be accessed by website admins. """, - response_model=list[OfficerTermResponse], + response_model=list[OfficerTerm], responses={ - 401: { "description": "not logged in", "model": DetailModel }, - 403: { "description": "not authorized to view private info", "model": DetailModel } + 401: {"description": "not logged in", "model": DetailModel}, + 403: {"description": "not authorized to view private info", "model": DetailModel}, }, - operation_id="get_officer_terms_by_id" + operation_id="get_officer_terms_by_id", ) async def get_officer_terms( - request: Request, - db_session: database.DBSession, - computing_id: str, - include_future_terms: bool = False + user_id: SessionUser, db_session: database.DBSession, computing_id: str, include_future_terms: bool = False ): if include_future_terms: - _, session_computing_id = await logged_in_or_raise(request, db_session) - if computing_id != session_computing_id: - await WebsiteAdmin.has_permission_or_raise(db_session, session_computing_id) + await verify_update(user_id, db_session, computing_id) # all term info is public, so anyone can get any of it - officer_terms = await officers.crud.get_officer_terms( - db_session, - computing_id, - include_future_terms - ) - return JSONResponse([ - term.serializable_dict() for term in officer_terms - ]) + officer_terms = await officers.crud.get_officer_terms(db_session, computing_id, include_future_terms) + return JSONResponse([OfficerTerm.model_validate(term).model_dump(mode="json") for term in officer_terms]) + @router.get( - "/info/{computing_id:str}", + "/info/{computing_id}", description="Get officer info for the current user, if they've ever been an exec. Only admins can get info about another user.", - response_model=OfficerInfoResponse, - responses={ - 403: { "description": "not authorized to view author user info", "model": DetailModel } - }, - operation_id="get_officer_info_by_id" + response_model=OfficerInfo, + responses={403: {"description": "not authorized to view author user info", "model": DetailModel}}, + operation_id="get_officer_info_by_id", ) async def get_officer_info( - request: Request, db_session: database.DBSession, + session_computing_id: LoggedInUser, computing_id: str, ): - _, session_computing_id = await logged_in_or_raise(request, db_session) - if computing_id != session_computing_id: - await WebsiteAdmin.has_permission_or_raise( - db_session, session_computing_id, - errmsg="must have website admin permissions to get officer info about another user" - ) + if computing_id != session_computing_id and not await is_user_website_admin(session_computing_id, db_session): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="not authorized") officer_info = await officers.crud.get_officer_info_or_raise(db_session, computing_id) return JSONResponse(officer_info.serializable_dict()) + @router.post( "/term", description=""" Only the sysadmin, president, or DoA can submit this request. It will usually be the DoA. Updates the system with a new officer, and enables the user to login to the system to input their information. """, - response_model=SuccessResponse, + response_model=list[OfficerTerm], responses={ - 403: { "description": "must be a website admin", "model": DetailModel }, - 500: { "model": DetailModel }, + 403: {"description": "must be a website admin", "model": DetailModel}, + 500: {"model": DetailModel}, }, - operation_id="create_officer_term" + operation_id="create_officer_term", + dependencies=[Depends(perm_admin)], ) async def create_officer_term( - request: Request, db_session: database.DBSession, - officer_info_list: list[OfficerTermCreate], + officer_list: list[OfficerCreate], ): - await admin_or_raise(request, db_session) - - for officer_info in officer_info_list: - # if user with officer_info.computing_id has never logged into the website before, - # a site_user tuple will be created for them. - await officers.crud.create_new_officer_info(db_session, OfficerInfo( - computing_id = officer_info.computing_id, - # TODO (#71): use sfu api to get legal name from officer_info.computing_id - legal_name = officer_info.legal_name, - phone_number = None, - - discord_id = None, - discord_name = None, - discord_nickname = None, - - google_drive_email = None, - github_username = None, - )) - await officers.crud.create_new_officer_term(db_session, OfficerTerm( - computing_id = officer_info.computing_id, - position = officer_info.position, - start_date = officer_info.start_date, - )) + new_terms = await officers.crud.create_multiple_officers(db_session, officer_list) + content = [term.serializable_dict() for term in new_terms] await db_session.commit() - return JSONResponse({ "success": True }) + return JSONResponse(content) + @router.patch( "/info/{computing_id}", @@ -208,23 +172,20 @@ async def create_officer_term( If you have been elected as a new officer, you may authenticate with SFU CAS, then input your information & the valid token for us. Admins may update this info. """, - response_model=OfficerInfoResponse, + response_model=OfficerInfo, responses={ - 403: { "description": "must be a website admin", "model": DetailModel }, - 500: { "description": "failed to fetch after update", "model": DetailModel }, + 403: {"description": "must be a website admin", "model": DetailModel}, + 500: {"description": "failed to fetch after update", "model": DetailModel}, }, - operation_id="update_officer_info" + operation_id="update_officer_info", ) async def update_officer_info( - request: Request, + user_id: SessionUser, db_session: database.DBSession, computing_id: str, - officer_info_upload: OfficerUpdate | OfficerSelfUpdate + officer_info_upload: OfficerUpdate, ): - is_site_admin, _, session_computing_id = await is_website_admin(request, db_session) - - if computing_id != session_computing_id and not is_site_admin: - raise HTTPException(status_code=403, detail="you may not update other officers") + await verify_update(user_id, db_session, computing_id) old_officer_info = await officers.crud.get_officer_info_or_raise(db_session, computing_id) old_officer_info.update_from_params(officer_info_upload) @@ -237,65 +198,62 @@ async def update_officer_info( updated_officer_info = await officers.crud.get_new_officer_info_or_raise(db_session, computing_id) return JSONResponse(updated_officer_info.serializable_dict()) + @router.patch( "/term/{term_id}", description="Update the information for an Officer's term", - response_model=OfficerTermResponse, + response_model=OfficerTerm, responses={ - 403: { "description": "must be a website admin", "model": DetailModel }, - 500: { "description": "failed to fetch after update", "model": DetailModel }, + 403: {"description": "must be a website admin", "model": DetailModel}, + 500: {"description": "failed to fetch after update", "model": DetailModel}, }, - operation_id="update_officer_term_by_id" + operation_id="update_officer_term_by_id", + dependencies=[Depends(perm_admin)], ) -async def update_officer_term( - request: Request, - db_session: database.DBSession, - term_id: int, - body: OfficerTermUpdate -): +async def update_officer_term(db_session: database.DBSession, term_id: int, body: OfficerTermUpdate): """ A website admin may change the position & term length however they wish. + For now, only website admins can change these things. """ - is_site_admin, _, session_computing_id = await is_website_admin(request, db_session) old_officer_term = await officers.crud.get_officer_term_by_id_or_raise(db_session, term_id) - if not is_site_admin: - if old_officer_term.computing_id != session_computing_id: - raise HTTPException(status_code=403, detail="you may not update other officer terms") - if utils.is_past_term(old_officer_term): - raise HTTPException(status_code=403, detail="you may not update past terms") + # TODO: Enable this check if we allow non-website admins to change their information + # if utils.is_past_term(old_officer_term): + # raise HTTPException(status_code=403, detail="you may not update past terms") - old_officer_term.update_from_params(body, is_site_admin) + new_data = body.model_dump(exclude_unset=True) + + for key, value in new_data.items(): + setattr(old_officer_term, key, value) # TODO (#27): log all important changes to a .log file await officers.crud.update_officer_term(db_session, old_officer_term) await db_session.commit() + await db_session.refresh(old_officer_term) + + return JSONResponse(OfficerTerm.model_validate(old_officer_term).model_dump(mode="json")) - new_officer_term = await officers.crud.get_officer_term_by_id_or_raise(db_session, term_id) - return JSONResponse(new_officer_term.serializable_dict()) @router.delete( - "/term/{term_id:int}", + "/term/{term_id}", description="Remove the specified officer term. Only website admins can run this endpoint. BE CAREFUL WITH THIS!", response_model=SuccessResponse, responses={ - 401: { "description": "must be logged in", "model": DetailModel }, - 403: { "description": "must be a website admin", "model": DetailModel }, - 500: { "description": "server error", "model": DetailModel }, + 401: {"description": "must be logged in", "model": DetailModel}, + 403: {"description": "must be a website admin", "model": DetailModel}, + 500: {"description": "server error", "model": DetailModel}, }, - operation_id="delete_officer_term_by_id" + operation_id="delete_officer_term_by_id", + dependencies=[Depends(perm_admin)], ) async def delete_officer_term( - request: Request, db_session: database.DBSession, term_id: int, ): - await admin_or_raise(request, db_session) - # TODO (#27): log all important changes to a .log file - + # TODO: Double check that the delete was successful await officers.crud.delete_officer_term_by_id(db_session, term_id) await db_session.commit() diff --git a/src/permission/types.py b/src/permission/types.py index f0a37658..4572bd74 100644 --- a/src/permission/types.py +++ b/src/permission/types.py @@ -1,16 +1,12 @@ from datetime import date -from typing import ClassVar - -from fastapi import HTTPException import database -import officers.constants import officers.crud import utils from data.semesters import step_semesters -from officers.constants import OfficerPositionEnum +# TODO: Determine if we still need this class OfficerPrivateInfo: @staticmethod async def has_permission(db_session: database.DBSession, computing_id: str) -> bool: @@ -29,52 +25,3 @@ async def has_permission(db_session: database.DBSession, computing_id: str) -> b return True return False - -class ElectionOfficer: - @staticmethod - async def has_permission(db_session: database.DBSession, computing_id: str) -> bool: - """ - An current election officer has access to all election, prior election officers have no access. - """ - officer_terms = await officers.crud.current_officers(db_session, True) - current_election_officer = officer_terms.get( - officers.constants.OfficerPositionEnum.ELECTIONS_OFFICER - ) - if current_election_officer is not None: - for election_officer in current_election_officer[1]: - if ( - election_officer.private_data.computing_id == computing_id - and election_officer.is_current_officer - ): - return True - - return False - -class WebsiteAdmin: - WEBSITE_ADMIN_POSITIONS: ClassVar[list[OfficerPositionEnum]] = [ - OfficerPositionEnum.PRESIDENT, - OfficerPositionEnum.VICE_PRESIDENT, - OfficerPositionEnum.DIRECTOR_OF_ARCHIVES, - OfficerPositionEnum.SYSTEM_ADMINISTRATOR, - OfficerPositionEnum.WEBMASTER, - ] - - @staticmethod - async def has_permission(db_session: database.DBSession, computing_id: str) -> bool: - """ - A website admin has to be an active officer who has one of the above positions - """ - for position in await officers.crud.current_officer_positions(db_session, computing_id): - if position in WebsiteAdmin.WEBSITE_ADMIN_POSITIONS: - return True - return False - - @staticmethod - async def has_permission_or_raise( - db_session: database.DBSession, - computing_id: str, - errmsg:str = "must have website admin permissions" - ) -> bool: - if not await WebsiteAdmin.has_permission(db_session, computing_id): - raise HTTPException(status_code=403, detail=errmsg) - return True diff --git a/src/permission/urls.py b/src/permission/urls.py index 0fdf9c5a..eadf552f 100644 --- a/src/permission/urls.py +++ b/src/permission/urls.py @@ -1,30 +1,27 @@ -from fastapi import APIRouter, HTTPException, Request +from fastapi import APIRouter, Depends from fastapi.responses import JSONResponse -import auth.crud -import database -from permission.types import WebsiteAdmin +from dependencies import perm_admin router = APIRouter( prefix="/permission", tags=["permission"], ) + @router.get( "/is_admin", - description="checks if the current user has the admin permission" + description="checks if the current user has the admin permission", + dependencies=[Depends(perm_admin)], ) -async def is_admin( - request: Request, - db_session: database.DBSession, -): - session_id = request.cookies.get("session_id", None) - if session_id is None: - raise HTTPException(status_code=401, detail="must be logged in") - - computing_id = await auth.crud.get_computing_id(db_session, session_id) - if computing_id is None: - raise HTTPException(status_code=401, detail="must be logged in (no computing_id)") - - is_admin_permission = await WebsiteAdmin.has_permission(db_session, computing_id) - return JSONResponse({"is_admin": is_admin_permission}) +async def is_admin(): + # session_id = request.cookies.get("session_id", None) + # if session_id is None: + # raise HTTPException(status_code=401, detail="must be logged in") + # + # computing_id = await auth.crud.get_computing_id(db_session, session_id) + # if computing_id is None: + # raise HTTPException(status_code=401, detail="must be logged in (no computing_id)") + # + # is_admin_permission = await WebsiteAdmin.has_permission(db_session, computing_id) + return JSONResponse({"is_admin": True}) diff --git a/src/registrations/crud.py b/src/registrations/crud.py index 6d135b50..18df6aa8 100644 --- a/src/registrations/crud.py +++ b/src/registrations/crud.py @@ -8,63 +8,54 @@ async def get_all_registrations_of_user( - db_session: AsyncSession, - computing_id: str, - election_slug: str + db_session: AsyncSession, computing_id: str, election_slug: str ) -> Sequence[NomineeApplication] | None: - registrations = (await db_session.scalars( - sqlalchemy - .select(NomineeApplication) - .where( - (NomineeApplication.computing_id == computing_id) - & (NomineeApplication.nominee_election == election_slug) + registrations = ( + await db_session.scalars( + sqlalchemy.select(NomineeApplication).where( + (NomineeApplication.computing_id == computing_id) + & (NomineeApplication.nominee_election == election_slug) + ) ) - )).all() + ).all() return registrations + async def get_one_registration_in_election( db_session: AsyncSession, computing_id: str, election_slug: str, position: OfficerPositionEnum, ) -> NomineeApplication | None: - registration = (await db_session.scalar( - sqlalchemy - .select(NomineeApplication) - .where( + registration = await db_session.scalar( + sqlalchemy.select(NomineeApplication).where( NomineeApplication.computing_id == computing_id, NomineeApplication.nominee_election == election_slug, - NomineeApplication.position == position + NomineeApplication.position == position, ) - )) + ) return registration + async def get_all_registrations_in_election( db_session: AsyncSession, election_slug: str, ) -> Sequence[NomineeApplication] | None: - registrations = (await db_session.scalars( - sqlalchemy - .select(NomineeApplication) - .where( - NomineeApplication.nominee_election == election_slug + registrations = ( + await db_session.scalars( + sqlalchemy.select(NomineeApplication).where(NomineeApplication.nominee_election == election_slug) ) - )).all() + ).all() return registrations -async def add_registration( - db_session: AsyncSession, - initial_application: NomineeApplication -): + +async def add_registration(db_session: AsyncSession, initial_application: NomineeApplication): db_session.add(initial_application) -async def update_registration( - db_session: AsyncSession, - initial_application: NomineeApplication -): + +async def update_registration(db_session: AsyncSession, initial_application: NomineeApplication): await db_session.execute( - sqlalchemy - .update(NomineeApplication) + sqlalchemy.update(NomineeApplication) .where( (NomineeApplication.computing_id == initial_application.computing_id) & (NomineeApplication.nominee_election == initial_application.nominee_election) @@ -73,19 +64,14 @@ async def update_registration( .values(initial_application.to_update_dict()) ) + async def delete_registration( - db_session: AsyncSession, - computing_id: str, - election_slug: str, - position: OfficerPositionEnum + db_session: AsyncSession, computing_id: str, election_slug: str, position: OfficerPositionEnum ): await db_session.execute( - sqlalchemy - .delete(NomineeApplication) - .where( + sqlalchemy.delete(NomineeApplication).where( (NomineeApplication.computing_id == computing_id) & (NomineeApplication.nominee_election == election_slug) & (NomineeApplication.position == position) ) ) - diff --git a/src/registrations/models.py b/src/registrations/models.py index 7a26a68f..60975a34 100644 --- a/src/registrations/models.py +++ b/src/registrations/models.py @@ -12,17 +12,19 @@ class RegistrationModel(BaseModel): discord_username: str speech: str + class NomineeApplicationParams(BaseModel): computing_id: str position: OfficerPositionEnum + class NomineeApplicationUpdateParams(BaseModel): position: OfficerPositionEnum | None = None speech: str | None = None + class NomineeApplicationModel(BaseModel): computing_id: str nominee_election: str position: OfficerPositionEnum speech: str | None = None - diff --git a/src/registrations/tables.py b/src/registrations/tables.py index 27b98aaf..66b58ac1 100644 --- a/src/registrations/tables.py +++ b/src/registrations/tables.py @@ -15,16 +15,13 @@ class NomineeApplication(Base): speech: Mapped[str | None] = mapped_column(Text) - __table_args__ = ( - PrimaryKeyConstraint(computing_id, nominee_election, position), - ) + __table_args__ = (PrimaryKeyConstraint(computing_id, nominee_election, position),) def serialize(self) -> dict: return { "computing_id": self.computing_id, "nominee_election": self.nominee_election, "position": self.position, - "speech": self.speech, } @@ -33,7 +30,6 @@ def to_update_dict(self) -> dict: "computing_id": self.computing_id, "nominee_election": self.nominee_election, "position": self.position, - "speech": self.speech, } @@ -41,5 +37,3 @@ def update_from_params(self, params: NomineeApplicationUpdateParams): update_data = params.model_dump(exclude_unset=True) for k, v in update_data.items(): setattr(self, k, v) - - diff --git a/src/registrations/urls.py b/src/registrations/urls.py index f723c4fe..69509417 100644 --- a/src/registrations/urls.py +++ b/src/registrations/urls.py @@ -1,12 +1,13 @@ import datetime -from fastapi import APIRouter, HTTPException, Request, status +from fastapi import APIRouter, Depends, HTTPException, Request, status from fastapi.responses import JSONResponse import database import elections.crud import nominees.crud import registrations.crud +from dependencies import logged_in_user, perm_election from elections.models import ( ElectionStatusEnum, ) @@ -18,83 +19,66 @@ ) from registrations.tables import NomineeApplication from utils.shared_models import DetailModel, SuccessResponse -from utils.urls import AdminTypeEnum, admin_or_raise, logged_in_or_raise, slugify +from utils.urls import slugify router = APIRouter( prefix="/registration", tags=["registration"], ) + @router.get( "/{election_name:str}", description="get all the registrations of a single election", response_model=list[NomineeApplicationModel], responses={ - 401: { "description": "Not logged in", "model": DetailModel }, - 404: { "description": "Election with slug does not exist", "model": DetailModel } - }, - operation_id="get_election_registrations" + 401: {"description": "Not logged in", "model": DetailModel}, + 404: {"description": "Election with slug does not exist", "model": DetailModel}, + }, + operation_id="get_election_registrations", + dependencies=[Depends(logged_in_user)], ) -async def get_election_registrations( - request: Request, - db_session: database.DBSession, - election_name: str -): - await logged_in_or_raise(request, db_session) - +async def get_election_registrations(db_session: database.DBSession, election_name: str): slugified_name = slugify(election_name) if await elections.crud.get_election(db_session, slugified_name) is None: raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"election with slug {slugified_name} does not exist" + status_code=status.HTTP_404_NOT_FOUND, detail=f"election with slug {slugified_name} does not exist" ) registration_list = await registrations.crud.get_all_registrations_in_election(db_session, slugified_name) if registration_list is None: return JSONResponse([]) - return JSONResponse([ - item.serialize() for item in registration_list - ]) + return JSONResponse([item.serialize() for item in registration_list]) + @router.post( "/{election_name:str}", description="Register for a specific position in this election, but doesn't set a speech. Returns the created entry.", response_model=NomineeApplicationModel, responses={ - 400: { "description": "Bad request", "model": DetailModel }, - 401: { "description": "Not logged in", "model": DetailModel }, - 403: { "description": "Not an admin", "model": DetailModel }, - 404: { "description": "No election found", "model": DetailModel }, + 400: {"description": "Bad request", "model": DetailModel}, + 401: {"description": "Not logged in", "model": DetailModel}, + 403: {"description": "Not an admin", "model": DetailModel}, + 404: {"description": "No election found", "model": DetailModel}, }, - operation_id="register" + operation_id="register", + dependencies=[Depends(perm_election)], ) -async def register_in_election( - request: Request, - db_session: database.DBSession, - body: NomineeApplicationParams, - election_name: str -): - await admin_or_raise(request, db_session, AdminTypeEnum.Election) - +async def register_in_election(db_session: database.DBSession, body: NomineeApplicationParams, election_name: str): if body.position not in OfficerPositionEnum: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"invalid position {body.position}" - ) + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"invalid position {body.position}") if await nominees.crud.get_nominee_info(db_session, body.computing_id) is None: # ensure that the user has a nominee info entry before allowing registration to occur. raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="must have submitted nominee info before registering" + status_code=status.HTTP_400_BAD_REQUEST, detail="must have submitted nominee info before registering" ) slugified_name = slugify(election_name) election = await elections.crud.get_election(db_session, slugified_name) if election is None: raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"election with slug {slugified_name} does not exist" + status_code=status.HTTP_404_NOT_FOUND, detail=f"election with slug {slugified_name} does not exist" ) if body.position not in election.available_positions: @@ -102,90 +86,79 @@ async def register_in_election( # not updating or deleting one raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=f"{body.position} is not available to register for in this election" + detail=f"{body.position} is not available to register for in this election", ) if election.status(datetime.datetime.now()) != ElectionStatusEnum.NOMINATIONS: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="registrations can only be made during the nomination period" + detail="registrations can only be made during the nomination period", ) if await registrations.crud.get_all_registrations_of_user(db_session, body.computing_id, slugified_name): raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="person is already registered in this election" + status_code=status.HTTP_400_BAD_REQUEST, detail="person is already registered in this election" ) # TODO: associate specific election officers with specific election, then don't # allow any election officer running an election to register for it - await registrations.crud.add_registration(db_session, NomineeApplication( - computing_id=body.computing_id, - nominee_election=slugified_name, - position=body.position, - speech=None - )) + await registrations.crud.add_registration( + db_session, + NomineeApplication( + computing_id=body.computing_id, nominee_election=slugified_name, position=body.position, speech=None + ), + ) await db_session.commit() registrant = await registrations.crud.get_one_registration_in_election( db_session, body.computing_id, slugified_name, body.position ) if not registrant: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="failed to find new registrant" - ) + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="failed to find new registrant") return registrant + @router.patch( "/{election_name:str}/{position:str}/{computing_id:str}", description="update the application of a specific registrant and return the changed entry", response_model=NomineeApplicationModel, responses={ - 400: { "description": "Bad request", "model": DetailModel }, - 401: { "description": "Not logged in", "model": DetailModel }, - 403: { "description": "Not an admin", "model": DetailModel }, - 404: { "description": "No election found", "model": DetailModel }, + 400: {"description": "Bad request", "model": DetailModel}, + 401: {"description": "Not logged in", "model": DetailModel}, + 403: {"description": "Not an admin", "model": DetailModel}, + 404: {"description": "No election found", "model": DetailModel}, }, - operation_id="update_registration" + operation_id="update_registration", + dependencies=[Depends(perm_election)], ) async def update_registration( - request: Request, db_session: database.DBSession, body: NomineeApplicationUpdateParams, election_name: str, computing_id: str, - position: OfficerPositionEnum + position: OfficerPositionEnum, ): - await admin_or_raise(request, db_session, AdminTypeEnum.Election) - if body.position not in OfficerPositionEnum: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"invalid position {body.position}" - ) + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"invalid position {body.position}") slugified_name = slugify(election_name) election = await elections.crud.get_election(db_session, slugified_name) if election is None: raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"election with slug {slugified_name} does not exist" + status_code=status.HTTP_404_NOT_FOUND, detail=f"election with slug {slugified_name} does not exist" ) # self updates can only be done during nomination period. Officer updates can be done whenever if election.status(datetime.datetime.now()) != ElectionStatusEnum.NOMINATIONS: raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="speeches can only be updated during the nomination period" + status_code=status.HTTP_400_BAD_REQUEST, detail="speeches can only be updated during the nomination period" ) - registration = await registrations.crud.get_one_registration_in_election(db_session, computing_id, slugified_name, position) + registration = await registrations.crud.get_one_registration_in_election( + db_session, computing_id, slugified_name, position + ) if not registration: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="no registration record found" - ) + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="no registration record found") registration.update_from_params(body) @@ -197,60 +170,55 @@ async def update_registration( ) if not registrant: raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="failed to find changed registrant" + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="failed to find changed registrant" ) return registrant + @router.delete( "/{election_name:str}/{position:str}/{computing_id:str}", description="delete the registration of a person", response_model=SuccessResponse, responses={ - 400: { "description": "Bad request", "model": DetailModel }, - 401: { "description": "Not logged in", "model": DetailModel }, - 403: { "description": "Not an admin", "model": DetailModel }, - 404: { "description": "No election or registrant found", "model": DetailModel }, + 400: {"description": "Bad request", "model": DetailModel}, + 401: {"description": "Not logged in", "model": DetailModel}, + 403: {"description": "Not an admin", "model": DetailModel}, + 404: {"description": "No election or registrant found", "model": DetailModel}, }, - operation_id="delete_registration" + operation_id="delete_registration", + dependencies=[Depends(perm_election)], ) async def delete_registration( - request: Request, db_session: database.DBSession, election_name: str, position: OfficerPositionEnum, - computing_id: str + computing_id: str, ): - await admin_or_raise(request, db_session, AdminTypeEnum.Election) - if position not in OfficerPositionEnum: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"invalid position {position}" - ) + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"invalid position {position}") slugified_name = slugify(election_name) election = await elections.crud.get_election(db_session, slugified_name) if election is None: raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"election with slug {slugified_name} does not exist" + status_code=status.HTTP_404_NOT_FOUND, detail=f"election with slug {slugified_name} does not exist" ) if election.status(datetime.datetime.now()) != ElectionStatusEnum.NOMINATIONS: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="registration can only be revoked during the nomination period" + detail="registration can only be revoked during the nomination period", ) if not await registrations.crud.get_all_registrations_of_user(db_session, computing_id, slugified_name): raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail=f"{computing_id} was not registered in election {slugified_name} for {position}" + detail=f"{computing_id} was not registered in election {slugified_name} for {position}", ) await registrations.crud.delete_registration(db_session, computing_id, slugified_name, position) await db_session.commit() - old_election = await registrations.crud.get_one_registration_in_election(db_session, computing_id, slugified_name, position) + old_election = await registrations.crud.get_one_registration_in_election( + db_session, computing_id, slugified_name, position + ) return JSONResponse({"success": old_election is None}) - diff --git a/src/scripts/migrate_from_about_officers.py b/src/scripts/migrate_from_about_officers.py index 2e393c5d..51aaad5d 100644 --- a/src/scripts/migrate_from_about_officers.py +++ b/src/scripts/migrate_from_about_officers.py @@ -10,11 +10,11 @@ sys.path.append(str(Path(__file__).parent.parent.resolve())) from auth.crud import site_user_exists -from auth.tables import SiteUser +from auth.tables import SiteUserDB from data import semesters from database import SQLALCHEMY_TEST_DATABASE_URL, DatabaseSessionManager from officers.constants import OfficerPosition -from officers.types import OfficerInfo, OfficerTerm +from officers.types import OfficerInfoDB, OfficerTermDB # This loads officer data from the https://github.com/CSSS/csss-site database into the provided database @@ -22,12 +22,13 @@ # NOTE: pass either SQLALCHEMY_DATABASE_URL or SQLALCHEMY_TEST_DATABASE_URL DB_TARGET = os.environ.get("DB_TARGET") + async def main(): conn = await asyncpg.connect( user="postgres", password=DB_PASSWORD, database="postgres", - host="sfucsss.org", # NOTE: this should point to the old sfucsss.org server (made initially by jace) + host="sfucsss.org", # NOTE: this should point to the old sfucsss.org server (made initially by jace) port=5432, ) @@ -59,12 +60,13 @@ def get_key(officer): unique_terms[key] = (officer["id"], 1) else: # if there is a term with the same start date, position, and computing_id, take only the last instance. - unique_terms[key] = (officer["id"], unique_terms[key][1]+1) + unique_terms[key] = (officer["id"], unique_terms[key][1] + 1) # computing num_semesters num_semesters_map = {} consolidated_officer_data = [ - officer for officer in officer_data + officer + for officer in officer_data # include only latest info in a term if unique_terms[get_key(officer)][0] == officer["id"] ] @@ -78,25 +80,25 @@ def get_key(officer): for officer in consolidated_officer_data: if officer["full_name"] == "Jace Manshadi": last_term_jace = officer - num_semesters_jace = sum([ - num_semesters_map[get_key(officer)] - for officer in consolidated_officer_data - if officer["full_name"] == "Jace Manshadi"] + num_semesters_jace = sum( + [ + num_semesters_map[get_key(officer)] + for officer in consolidated_officer_data + if officer["full_name"] == "Jace Manshadi" + ] ) - num_semesters_map[( - last_term_jace["start_date"], - last_term_jace["sfu_computing_id"], - last_term_jace["position_name"] - )] = num_semesters_jace + num_semesters_map[ + (last_term_jace["start_date"], last_term_jace["sfu_computing_id"], last_term_jace["position_name"]) + ] = num_semesters_jace consolidated_officer_data = [ officer for officer in consolidated_officer_data if officer["full_name"] != "Jace Manshadi" ] + [last_term_jace] await conn.close() - #print("\n\n".join([str(x) for x in consolidated_officer_data[100:]])) + # print("\n\n".join([str(x) for x in consolidated_officer_data[100:]])) - sessionmanager = DatabaseSessionManager(DB_TARGET, { "echo": False }, check_db=False) + sessionmanager = DatabaseSessionManager(DB_TARGET, {"echo": False}, check_db=False) await DatabaseSessionManager.test_connection(DB_TARGET) async with sessionmanager.session() as db_session: # NOTE: keep an eye out for bugs with legacy officer position names, as any not in OfficerPosition should be considered inactive @@ -143,40 +145,37 @@ def get_key(officer): if not await site_user_exists(db_session, officer["sfu_computing_id"]): # if computing_id has not been created as a site_user yet, add them - db_session.add(SiteUser( - computing_id=officer["sfu_computing_id"], - first_logged_in=datetime.now(), - last_logged_in=datetime.now() - )) + db_session.add( + SiteUserDB( + computing_id=officer["sfu_computing_id"], + first_logged_in=datetime.now(), + last_logged_in=datetime.now(), + ) + ) # use the most up to date officer info # -------------------------------- - new_officer_info = OfficerInfo( - computing_id = officer["sfu_computing_id"], - legal_name = officer["full_name"], - phone_number = str(officer["phone_number"]), - - discord_id = officer["discord_id"], - discord_name = officer["discord_username"], - discord_nickname = officer["discord_nickname"], - - google_drive_email = officer["gmail"], - github_username = officer["github_username"], + new_officer_info = OfficerInfoDB( + computing_id=officer["sfu_computing_id"], + legal_name=officer["full_name"], + phone_number=str(officer["phone_number"]), + discord_id=officer["discord_id"], + discord_name=officer["discord_username"], + discord_nickname=officer["discord_nickname"], + google_drive_email=officer["gmail"], + github_username=officer["github_username"], ) existing_officer_info = await db_session.scalar( - sqlalchemy - .select(OfficerInfo) - .where(OfficerInfo.computing_id == new_officer_info.computing_id) + sqlalchemy.select(OfficerInfoDB).where(OfficerInfoDB.computing_id == new_officer_info.computing_id) ) if existing_officer_info is None: db_session.add(new_officer_info) else: await db_session.execute( - sqlalchemy - .update(OfficerInfo) - .where(OfficerInfo.computing_id == new_officer_info.computing_id) + sqlalchemy.update(OfficerInfoDB) + .where(OfficerInfoDB.computing_id == new_officer_info.computing_id) .values(new_officer_info.to_update_dict()) ) @@ -194,9 +193,9 @@ def get_key(officer): if position_length is not None: if ( (officer["start_date"].date() < (date.today() - timedelta(days=365))) - and (officer["start_date"].date() > (date.today() - timedelta(days=365*5))) - and officer["id"] != 867 # sometimes people only run partial terms (elected_term_id=20222) - and officer["id"] != 942 # sometimes people only run partial terms + and (officer["start_date"].date() > (date.today() - timedelta(days=365 * 5))) + and officer["id"] != 867 # sometimes people only run partial terms (elected_term_id=20222) + and officer["id"] != 942 # sometimes people only run partial terms ): # over the past few years, the semester length should be as expected if not (position_length == num_semesters): @@ -214,20 +213,18 @@ def get_key(officer): num_semesters, ) - new_officer_term = OfficerTerm( - computing_id = officer["sfu_computing_id"], - position = corrected_position_name, - - start_date = officer["start_date"], - end_date = computed_end_date, - - nickname = None, - favourite_course_0 = officer["course1"], - favourite_course_1 = officer["course2"], - favourite_pl_0 = officer["language1"], - favourite_pl_1 = officer["language2"], - biography = officer["bio"], - photo_url = officer["image"], + new_officer_term = OfficerTermDB( + computing_id=officer["sfu_computing_id"], + position=corrected_position_name, + start_date=officer["start_date"], + end_date=computed_end_date, + nickname=None, + favourite_course_0=officer["course1"], + favourite_course_1=officer["course2"], + favourite_pl_0=officer["language1"], + favourite_pl_1=officer["language2"], + biography=officer["bio"], + photo_url=officer["image"], ) db_session.add(new_officer_term) @@ -236,4 +233,5 @@ def get_key(officer): print("successfully loaded data!") + asyncio.run(main()) diff --git a/src/utils/__init__.py b/src/utils/__init__.py index 62c95b62..5ec4afd6 100644 --- a/src/utils/__init__.py +++ b/src/utils/__init__.py @@ -6,7 +6,8 @@ # we can't use and/or in sql expressions, so we must use these functions from sqlalchemy.sql.expression import and_, or_ -from officers.tables import OfficerTerm +from officers.models import OfficerTerm +from officers.tables import OfficerTermDB def is_iso_format(date_str: str) -> bool: @@ -16,6 +17,7 @@ def is_iso_format(date_str: str) -> bool: except ValueError: return False + def is_active_officer(query: Select) -> Select: """ An active officer is one who is currently part of the CSSS officer team. @@ -24,22 +26,22 @@ def is_active_officer(query: Select) -> Select: return query.where( and_( # cannot be an officer who has not started yet - OfficerTerm.start_date <= date.today(), + OfficerTermDB.start_date <= date.today(), or_( # executives without a specified end_date are considered active - OfficerTerm.end_date.is_(None), + OfficerTermDB.end_date.is_(None), # check that today's timestamp is before (smaller than) the term's end date - date.today() <= OfficerTerm.end_date, - ) + date.today() <= OfficerTermDB.end_date, + ), ) ) -def has_started_term(query: Select) -> Select[tuple[OfficerTerm]]: - return query.where( - OfficerTerm.start_date <= date.today() - ) -def is_active_term(term: OfficerTerm) -> bool: +def has_started_term(query: Select) -> Select[tuple[OfficerTermDB]]: + return query.where(OfficerTermDB.start_date <= date.today()) + + +def is_active_term(term: OfficerTermDB) -> bool: return ( # cannot be an officer who has not started yet term.start_date <= date.today() @@ -51,7 +53,8 @@ def is_active_term(term: OfficerTerm) -> bool: ) ) -def is_past_term(term: OfficerTerm) -> bool: + +def is_past_term(term: OfficerTermDB | OfficerTerm) -> bool: """Any term which has concluded""" return ( # an officer with no end date is current @@ -60,12 +63,10 @@ def is_past_term(term: OfficerTerm) -> bool: and date.today() > term.end_date ) + def is_valid_phone_number(phone_number: str) -> bool: - return ( - len(phone_number) == 10 - and phone_number.isnumeric() - ) + return len(phone_number) == 10 and phone_number.isnumeric() + def is_valid_email(email: str): return re.match(r"^[^@]+@[^@]+\.[a-zA-Z]*$", email) - diff --git a/src/utils/permissions.py b/src/utils/permissions.py new file mode 100644 index 00000000..0cf40746 --- /dev/null +++ b/src/utils/permissions.py @@ -0,0 +1,86 @@ +from enum import Enum + +from fastapi import HTTPException, Request, status + +import auth +import database +import officers +from officers.constants import OfficerPositionEnum + +WEBSITE_ADMIN_POSITIONS: list[OfficerPositionEnum] = [ + OfficerPositionEnum.PRESIDENT, + OfficerPositionEnum.VICE_PRESIDENT, + OfficerPositionEnum.DIRECTOR_OF_ARCHIVES, + OfficerPositionEnum.SYSTEM_ADMINISTRATOR, + OfficerPositionEnum.WEBMASTER, +] + + +# Permissions are granted if the Enum value >= the level needed +class AdminTypeEnum(Enum): + Election = 1 + Full = 2 + + +async def is_user_website_admin(computing_id: str, db_session: database.DBSession) -> bool: + for position in await officers.crud.current_officer_positions(db_session, computing_id): + if position in WEBSITE_ADMIN_POSITIONS: + return True + + return False + + +# TODO: Add an election admin version that checks the election attempting to be modified as well +async def is_user_election_officer(computing_id: str, db_session: database.DBSession) -> bool: + """ + An current election officer has access to all election, prior election officers have no access. + """ + officer_terms = await officers.crud.get_current_terms_by_position(db_session, OfficerPositionEnum.ELECTIONS_OFFICER) + for officer in officer_terms: + if computing_id == officer.computing_id: + return True + + return False + + +async def get_user(request: Request, db_session: database.DBSession) -> tuple[str, str]: + """ + Get the user's computing ID and session ID. + + Args: + request: The request + db_session: Database session + + Returns: + A tuple of (session_id, computing_id) + + Raises: + HTTPException: User is not logged in + """ + session_id = request.cookies.get("session_id", None) + if session_id is None: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="no session id") + + session_computing_id = await auth.crud.get_computing_id(db_session, session_id) + if session_computing_id is None: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="no computing id") + + return session_id, session_computing_id + + +async def get_admin(request: Request, db_session: database.DBSession, admin_type: AdminTypeEnum) -> tuple[str, str]: + session_id, computing_id = await get_user(request, db_session) + + if ( + not is_user_website_admin(computing_id, db_session) + and not admin_type == AdminTypeEnum.Election + and is_user_election_officer(computing_id, db_session) + ): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="must be an admin") + + return (session_id, computing_id) + + +async def verify_update(computing_id: str, db_session: database.DBSession, target_id: str): + if target_id != computing_id and not await is_user_website_admin(computing_id, db_session): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="must be an admin") diff --git a/src/utils/shared_models.py b/src/utils/shared_models.py index 9d2032b5..3f246e17 100644 --- a/src/utils/shared_models.py +++ b/src/utils/shared_models.py @@ -4,8 +4,10 @@ class SuccessResponse(BaseModel): success: bool + class DetailModel(BaseModel): detail: str + class MessageModel(BaseModel): message: str diff --git a/src/utils/types.py b/src/utils/types.py index 71f99df6..27b0137f 100644 --- a/src/utils/types.py +++ b/src/utils/types.py @@ -1,4 +1,3 @@ - from sqlalchemy import Dialect from sqlalchemy.types import Text, TypeDecorator @@ -19,5 +18,3 @@ def process_result_value(self, value, dialect: Dialect) -> list[str]: if value is None or value == "": return [] return value.split(",") - - diff --git a/src/utils/urls.py b/src/utils/urls.py index 6d4c3916..24392858 100644 --- a/src/utils/urls.py +++ b/src/utils/urls.py @@ -1,84 +1,7 @@ import re -from enum import Enum - -from fastapi import HTTPException, Request, status - -import auth -import auth.crud -import database -from permission.types import ElectionOfficer, WebsiteAdmin - - -class AdminTypeEnum(Enum): - Full = 1 - Election = 2 # TODO: move other utils into this module def slugify(text: str) -> str: """Creates a unique slug based on text passed in. Assumes non-unicode text.""" return re.sub(r"[\W_]+", "-", text.strip().replace("/", "").replace("&", "")) - -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, detail="no session id") - - session_computing_id = await auth.crud.get_computing_id(db_session, session_id) - if session_computing_id is None: - raise HTTPException(status_code=401, detail="no computing id") - - return session_id, session_computing_id - -async def get_current_user(request: Request, db_session: database.DBSession) -> tuple[str, str] | tuple[None, None]: - """ - Gets information about the currently logged in user. - - Args: - request: The request being checked - db_session: The current database session - - Returns: - A tuple of either (None, None) if there is no logged in user or a tuple (session ID, computing ID) - """ - session_id = request.cookies.get("session_id", None) - if session_id is None: - return None, None - - session_computing_id = await auth.crud.get_computing_id(db_session, session_id) - if session_computing_id is None: - return None, None - - return session_id, session_computing_id - -# TODO: Add an election admin version that checks the election attempting to be modified as well -async def admin_or_raise(request: Request, db_session: database.DBSession, admintype: AdminTypeEnum = AdminTypeEnum.Full) -> tuple[str, str]: - session_id, computing_id = await get_current_user(request, db_session) - if not session_id or not computing_id: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="must be logged in" - ) - - # where valid means election officer or website admin - if (await WebsiteAdmin.has_permission(db_session, computing_id)) or (admintype is AdminTypeEnum.Election and await ElectionOfficer.has_permission(db_session, computing_id)): - return session_id, computing_id - - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="must be an admin" - ) - -async def is_website_admin(request: Request, db_session: database.DBSession) -> tuple[bool, str | None, str | None]: - session_id, computing_id = await get_current_user(request, db_session) - if session_id is None or computing_id is None: - return False, session_id, computing_id - - if (await WebsiteAdmin.has_permission(db_session, computing_id)): - return True, session_id, computing_id - - return False, session_id, computing_id diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 88c8230b..dc3aef15 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -7,10 +7,10 @@ import pytest_asyncio from httpx import ASGITransport, AsyncClient -from src import load_test_db -from src.auth.crud import create_user_session, remove_user_session -from src.database import SQLALCHEMY_TEST_DATABASE_URL, DatabaseSessionManager -from src.main import app +from auth.crud import create_user_session, remove_user_session +from database import SQLALCHEMY_TEST_DATABASE_URL, DatabaseSessionManager +from load_test_db import SYSADMIN_COMPUTING_ID, async_main +from main import app # This might be able to be moved to `package` scope as long as I inject it to every test function @@ -20,17 +20,19 @@ def suppress_sqlalchemy_logs(): yield logging.getLogger("sqlalchemy.engine").setLevel(logging.INFO) + @pytest_asyncio.fixture(scope="session", loop_scope="session") async def database_setup(): # reset the database again, just in case print("Resetting DB...") sessionmanager = DatabaseSessionManager(SQLALCHEMY_TEST_DATABASE_URL, {"echo": False}, check_db=False) # this resets the contents of the database to be whatever is from `load_test_db.py` - await load_test_db.async_main(sessionmanager) + await async_main(sessionmanager) print("Done setting up!") yield sessionmanager await sessionmanager.close() + @pytest_asyncio.fixture(scope="session", loop_scope="session") async def client() -> AsyncGenerator[Any, None]: # base_url is just a random placeholder url @@ -39,18 +41,18 @@ async def client() -> AsyncGenerator[Any, None]: async with AsyncClient(transport=ASGITransport(app), base_url="http://test") as client: yield client + @pytest_asyncio.fixture(scope="function", loop_scope="session") async def db_session(database_setup): async with database_setup.session() as session: yield session + @pytest_asyncio.fixture(scope="module", loop_scope="session") async def admin_client(database_setup, client): - session_id = "temp_id_" + load_test_db.SYSADMIN_COMPUTING_ID - client.cookies = { "session_id": session_id } + session_id = "temp_id_" + SYSADMIN_COMPUTING_ID + client.cookies = {"session_id": session_id} async with database_setup.session() as session: - await create_user_session(session, session_id, load_test_db.SYSADMIN_COMPUTING_ID) + await create_user_session(session, session_id, SYSADMIN_COMPUTING_ID) yield client await remove_user_session(session, session_id) - - diff --git a/tests/integration/test_elections.py b/tests/integration/test_elections.py index bf402a95..7159e10d 100644 --- a/tests/integration/test_elections.py +++ b/tests/integration/test_elections.py @@ -5,18 +5,18 @@ import pytest from httpx import ASGITransport, AsyncClient -from src import load_test_db -from src.auth.crud import create_user_session -from src.database import SQLALCHEMY_TEST_DATABASE_URL, DatabaseSessionManager -from src.elections.crud import ( +import load_test_db +from auth.crud import create_user_session +from database import SQLALCHEMY_TEST_DATABASE_URL, DatabaseSessionManager +from elections.crud import ( get_all_elections, get_election, ) -from src.main import app -from src.nominees.crud import ( +from main import app +from nominees.crud import ( get_nominee_info, ) -from src.registrations.crud import ( +from registrations.crud import ( get_all_registrations_in_election, ) @@ -25,6 +25,7 @@ def anyio_backend(): return "asyncio" + # creates HTTP test client for making requests @pytest.fixture(scope="session") async def client(): @@ -33,6 +34,7 @@ async def client(): async with AsyncClient(transport=ASGITransport(app), base_url="http://test") as client: yield client + # run this again for every function # sets up a clean database for each test function @pytest.fixture(scope="function") @@ -47,40 +49,41 @@ async def database_setup(): return sessionmanager + # database testing------------------------------- @pytest.mark.asyncio async def test_read_elections(database_setup): sessionmanager = await database_setup async with sessionmanager.session() as db_session: - # test that reads from the database succeeded as expected - elections = await get_all_elections(db_session) - assert elections is not None - assert len(elections) > 0 - - # False data test - election_false = await get_election(db_session, "this-not-a-election") - assert election_false is None - - # Test getting specific election - election = await get_election(db_session, "test-election-1") - assert election is not None - assert election.slug == "test-election-1" - assert election.name == "test election 1" - assert election.type == "general_election" - assert election.survey_link == "https://youtu.be/dQw4w9WgXcQ?si=kZROi2tu-43MXPM5" - - # Test getting a specific registration - registrations = await get_all_registrations_in_election(db_session, "test-election-1") - assert registrations is not None - - # Test getting the nominee info - nominee_info = await get_nominee_info(db_session, "jdo12") - assert nominee_info is not None - assert nominee_info.full_name == "John Doe" - assert nominee_info.email == "john_doe@doe.com" - assert nominee_info.discord_username == "doedoe" - assert nominee_info.linked_in == "linkedin.com/john-doe" - assert nominee_info.instagram == "john_doe" + # test that reads from the database succeeded as expected + elections = await get_all_elections(db_session) + assert elections is not None + assert len(elections) > 0 + + # False data test + election_false = await get_election(db_session, "this-not-a-election") + assert election_false is None + + # Test getting specific election + election = await get_election(db_session, "test-election-1") + assert election is not None + assert election.slug == "test-election-1" + assert election.name == "test election 1" + assert election.type == "general_election" + assert election.survey_link == "https://youtu.be/dQw4w9WgXcQ?si=kZROi2tu-43MXPM5" + + # Test getting a specific registration + registrations = await get_all_registrations_in_election(db_session, "test-election-1") + assert registrations is not None + + # Test getting the nominee info + nominee_info = await get_nominee_info(db_session, "jdo12") + assert nominee_info is not None + assert nominee_info.full_name == "John Doe" + assert nominee_info.email == "john_doe@doe.com" + assert nominee_info.discord_username == "doedoe" + assert nominee_info.linked_in == "linkedin.com/john-doe" + assert nominee_info.instagram == "john_doe" # API endpoint testing (without AUTH)-------------------------------------- @@ -98,7 +101,7 @@ async def test_endpoints(client, database_setup): # if candidates filled, enure unauthorized values remain hidden if "candidates" in response.json() and response.json()["candidates"]: for cand in response.json()["candidates"]: - assert "computing_id" not in cand + assert "computing_id" not in cand # TODO: Move these tests to a registrations test function # ensure that registrations can be viewed @@ -109,50 +112,64 @@ async def test_endpoints(client, database_setup): response = await client.get("/nominee/pkn4") assert response.status_code == 401 - response = await client.post("/election", json={ - "name": election_name, - "type": "general_election", - "datetime_start_nominations": "2025-08-18T09:00:00Z", - "datetime_start_voting": "2025-09-03T09:00:00Z", - "datetime_end_voting": "2025-09-18T23:59:59Z", - "available_positions": ["president"], - "survey_link": "https://youtu.be/dQw4w9WgXcQ?si=kZROi2tu-43MXPM5" - }) - assert response.status_code == 401 # unauthorized access to create an election + response = await client.post( + "/election", + json={ + "name": election_name, + "type": "general_election", + "datetime_start_nominations": "2025-08-18T09:00:00Z", + "datetime_start_voting": "2025-09-03T09:00:00Z", + "datetime_end_voting": "2025-09-18T23:59:59Z", + "available_positions": ["president"], + "survey_link": "https://youtu.be/dQw4w9WgXcQ?si=kZROi2tu-43MXPM5", + }, + ) + assert response.status_code == 401 # unauthorized access to create an election # TODO: Move these tests to a registrations test function # ensure that registrations can be viewed - response = await client.post("/registration/{test-election-1}", json={ - "computing_id": "1234567", - "position": "president", - }) - assert response.status_code == 401 # unauthorized access to register candidates - - response = await client.patch(f"/election/{election_name}", json={ - "type": "general_election", - "datetime_start_nominations": "2025-08-18T09:00:00Z", - "datetime_start_voting": "2025-09-03T09:00:00Z", - "datetime_end_voting": "2025-09-18T23:59:59Z", - "available_positions": ["president", "treasurer"], - "survey_link": "https://youtu.be/dQw4w9WgXcQ?si=kZROi2tu-43MXPM5" - - }) + response = await client.post( + "/registration/{test-election-1}", + json={ + "computing_id": "1234567", + "position": "president", + }, + ) + assert response.status_code == 401 # unauthorized access to register candidates + + response = await client.patch( + f"/election/{election_name}", + json={ + "type": "general_election", + "datetime_start_nominations": "2025-08-18T09:00:00Z", + "datetime_start_voting": "2025-09-03T09:00:00Z", + "datetime_end_voting": "2025-09-18T23:59:59Z", + "available_positions": ["president", "treasurer"], + "survey_link": "https://youtu.be/dQw4w9WgXcQ?si=kZROi2tu-43MXPM5", + }, + ) assert response.status_code == 401 # TODO: Move these tests to a registrations test function - response = await client.patch(f"/registration/{election_name}/vice-president/{load_test_db.SYSADMIN_COMPUTING_ID}", json={ - "position": "president", - "speech": "I would like to run for president because I'm the best in Valorant at SFU." - }) + response = await client.patch( + f"/registration/{election_name}/vice-president/{load_test_db.SYSADMIN_COMPUTING_ID}", + json={ + "position": "president", + "speech": "I would like to run for president because I'm the best in Valorant at SFU.", + }, + ) assert response.status_code == 401 - response = await client.patch("/nominee/jdo12", json={ - "full_name": "John Doe VI", - "linked_in": "linkedin.com/john-doe-vi", - "instagram": "john_vi", - "email": "johndoe_vi@doe.com", - "discord_username": "johnyy" - }) + response = await client.patch( + "/nominee/jdo12", + json={ + "full_name": "John Doe VI", + "linked_in": "linkedin.com/john-doe-vi", + "instagram": "john_vi", + "email": "johndoe_vi@doe.com", + "discord_username": "johnyy", + }, + ) assert response.status_code == 401 response = await client.delete(f"/election/{election_name}") @@ -171,7 +188,7 @@ async def test_endpoints_admin(client, database_setup): async with database_setup.session() as db_session: await create_user_session(db_session, session_id, load_test_db.SYSADMIN_COMPUTING_ID) - client.cookies = { "session_id": session_id } + client.cookies = {"session_id": session_id} # test that more info is given if logged in & with access to it response = await client.get("/election") @@ -186,7 +203,7 @@ async def test_endpoints_admin(client, database_setup): # if candidates filled, enure unauthorized values remain hidden if "candidates" in response.json() and response.json()["candidates"]: for cand in response.json()["candidates"]: - assert "computing_id" in cand + assert "computing_id" in cand # TODO: Move these tests to a registrations test function # ensure that registrations can be viewed @@ -194,66 +211,82 @@ async def test_endpoints_admin(client, database_setup): assert response.status_code == 200 # ensure that authorized users can create an election - response = await client.post("/election", json={ - "name": "testElection4", - "type": "general_election", - "datetime_start_nominations": (datetime.datetime.now() - timedelta(days=1)).isoformat(), - "datetime_start_voting": (datetime.datetime.now() + timedelta(days=7)).isoformat(), - "datetime_end_voting": (datetime.datetime.now() + timedelta(days=14)).isoformat(), - "available_positions": ["president", "treasurer"], - "survey_link": "https://youtu.be/dQw4w9WgXcQ?si=kZROi2tu-43MXPM5" - }) + response = await client.post( + "/election", + json={ + "name": "testElection4", + "type": "general_election", + "datetime_start_nominations": (datetime.datetime.now() - timedelta(days=1)).isoformat(), + "datetime_start_voting": (datetime.datetime.now() + timedelta(days=7)).isoformat(), + "datetime_end_voting": (datetime.datetime.now() + timedelta(days=14)).isoformat(), + "available_positions": ["president", "treasurer"], + "survey_link": "https://youtu.be/dQw4w9WgXcQ?si=kZROi2tu-43MXPM5", + }, + ) assert response.status_code == 200 # ensure that user can create election without knowing each position type - response = await client.post("/election", json={ - "name": "byElection4", - "type": "by_election", - "datetime_start_nominations": (datetime.datetime.now() - timedelta(days=1)).isoformat(), - "datetime_start_voting": (datetime.datetime.now() + timedelta(days=7)).isoformat(), - "datetime_end_voting": (datetime.datetime.now() + timedelta(days=14)).isoformat(), - "survey_link": "https://youtu.be/dQw4w9WgXcQ?si=kZROi2tu-43MXPM5" - }) + response = await client.post( + "/election", + json={ + "name": "byElection4", + "type": "by_election", + "datetime_start_nominations": (datetime.datetime.now() - timedelta(days=1)).isoformat(), + "datetime_start_voting": (datetime.datetime.now() + timedelta(days=7)).isoformat(), + "datetime_end_voting": (datetime.datetime.now() + timedelta(days=14)).isoformat(), + "survey_link": "https://youtu.be/dQw4w9WgXcQ?si=kZROi2tu-43MXPM5", + }, + ) assert response.status_code == 200 # TODO: Move these tests to a registrations test function # ensure that registrations can be viewed # try to register for a past election -> should say nomination period expired testElection1 = "test election 1" - response = await client.post(f"/registration/{testElection1}", json={ - "computing_id": load_test_db.SYSADMIN_COMPUTING_ID, - "position": "president", - }) + response = await client.post( + f"/registration/{testElection1}", + json={ + "computing_id": load_test_db.SYSADMIN_COMPUTING_ID, + "position": "president", + }, + ) assert response.status_code == 400 assert "nomination period" in response.json()["detail"] # TODO: Move these tests to a registrations test function # ensure that registrations can be viewed # try to register for an invalid position will just throw a 422 - response = await client.post(f"/registration/{election_name}", json={ - "computing_id": load_test_db.SYSADMIN_COMPUTING_ID, - "position": "CEO", - }) + response = await client.post( + f"/registration/{election_name}", + json={ + "computing_id": load_test_db.SYSADMIN_COMPUTING_ID, + "position": "CEO", + }, + ) assert response.status_code == 422 # TODO: Move these tests to a registrations test function # ensure that registrations can be viewed # try to register in an unknown election - response = await client.post("/registration/unknownElection12345", json={ - "computing_id": load_test_db.SYSADMIN_COMPUTING_ID, - "position": "president", - }) + response = await client.post( + "/registration/unknownElection12345", + json={ + "computing_id": load_test_db.SYSADMIN_COMPUTING_ID, + "position": "president", + }, + ) assert response.status_code == 404 assert "does not exist" in response.json()["detail"] - - # TODO: Move these tests to a registrations test function # ensure that registrations can be viewed # register for an election correctly - response = await client.post(f"/registration/{election_name}", json={ - "computing_id": "jdo12", - "position": "president", - }) + response = await client.post( + f"/registration/{election_name}", + json={ + "computing_id": "jdo12", + "position": "president", + }, + ) assert response.status_code == 200 # TODO: Move these tests to a registrations test function @@ -265,39 +298,45 @@ async def test_endpoints_admin(client, database_setup): # TODO: Move these tests to a registrations test function # ensure that registrations can be viewed # duplicate registration - response = await client.post(f"/registration/{election_name}", json={ - "computing_id": "jdo12", - "position": "president", - }) + response = await client.post( + f"/registration/{election_name}", + json={ + "computing_id": "jdo12", + "position": "president", + }, + ) assert response.status_code == 400 assert "registered" in response.json()["detail"] # update the above election - response = await client.patch("/election/testElection4", json={ - "election_type": "general_election", - "datetime_start_nominations": (datetime.datetime.now() - timedelta(days=1)).isoformat(), - "datetime_start_voting": (datetime.datetime.now() + timedelta(days=7)).isoformat(), - "datetime_end_voting": (datetime.datetime.now() + timedelta(days=14)).isoformat(), - "available_positions": ["president", "vice-president", "treasurer"], # update this - "survey_link": "https://youtu.be/dQw4w9WgXcQ?si=kZROi2tu-43MXPM5" - }) + response = await client.patch( + "/election/testElection4", + json={ + "election_type": "general_election", + "datetime_start_nominations": (datetime.datetime.now() - timedelta(days=1)).isoformat(), + "datetime_start_voting": (datetime.datetime.now() + timedelta(days=7)).isoformat(), + "datetime_end_voting": (datetime.datetime.now() + timedelta(days=14)).isoformat(), + "available_positions": ["president", "vice-president", "treasurer"], # update this + "survey_link": "https://youtu.be/dQw4w9WgXcQ?si=kZROi2tu-43MXPM5", + }, + ) assert response.status_code == 200 # TODO: Move these tests to a registrations test function # ensure that registrations can be viewed # update the registration - await client.patch(f"/registration/{election_name}/vice-president/pkn4", json={ - "speech": "Vote for me as treasurer" - }) + await client.patch( + f"/registration/{election_name}/vice-president/pkn4", json={"speech": "Vote for me as treasurer"} + ) assert response.status_code == 200 # TODO: Move these tests to a registrations test function # ensure that registrations can be viewed # try updating a non-registered election - response = await client.patch("/registration/testElection4/pkn4", json={ - "position": "president", - "speech": "Vote for me as president, I am good at valorant." - }) + response = await client.patch( + "/registration/testElection4/pkn4", + json={"position": "president", "speech": "Vote for me as president, I am good at valorant."}, + ) assert response.status_code == 404 # delete an election @@ -315,10 +354,13 @@ async def test_endpoints_admin(client, database_setup): assert response.status_code == 200 # update nominee info - response = await client.patch(f"/nominee/{load_test_db.SYSADMIN_COMPUTING_ID}", json={ - "full_name": "Puneet N", - "linked_in": "linkedin.com/not-my-linkedin", - }) + response = await client.patch( + f"/nominee/{load_test_db.SYSADMIN_COMPUTING_ID}", + json={ + "full_name": "Puneet N", + "linked_in": "linkedin.com/not-my-linkedin", + }, + ) assert response.status_code == 200 response = await client.get(f"/nominee/{load_test_db.SYSADMIN_COMPUTING_ID}") diff --git a/tests/integration/test_officers.py b/tests/integration/test_officers.py index d2c43b7a..919ca95a 100644 --- a/tests/integration/test_officers.py +++ b/tests/integration/test_officers.py @@ -2,17 +2,19 @@ from datetime import date, timedelta import pytest +from fastapi import status from httpx import AsyncClient -from src import load_test_db -from src.officers.constants import OfficerPositionEnum -from src.officers.crud import all_officers, current_officers, get_active_officer_terms +import load_test_db +from officers.constants import OfficerPositionEnum +from officers.crud import current_officers, get_active_officer_terms, get_all_officers # TODO: setup a database on the CI machine & run this as a unit test then (since # this isn't really an integration test) pytestmark = pytest.mark.asyncio(loop_scope="session") + async def test__read_execs(db_session): # test that reads from the database succeeded as expected assert (await get_active_officer_terms(db_session, "blarg")) == [] @@ -44,14 +46,15 @@ async def test__read_execs(db_session): # assert next(iter(current_exec_team.values()))[0].private_data is not None # assert next(iter(current_exec_team.values()))[0].private_data.computing_id == "abc11" - all_terms = await all_officers(db_session, include_future_terms=False) + all_terms = await get_all_officers(db_session, False, False) assert len(all_terms) == 8 -#async def test__update_execs(database_setup): +# async def test__update_execs(database_setup): # # TODO: the second time an update_officer_info call occurs, the user should be updated with info # pass + async def test__get_officers(client): # private data shouldn't be leaked response = await client.get("/officers/current") @@ -99,7 +102,8 @@ async def test__get_officers(client): assert "photo_url" not in response.json()[0] response = await client.get("/officers/all?include_future_terms=true") - assert response.status_code == 401 + assert response.status_code == status.HTTP_401_UNAUTHORIZED + async def test__get_officer_terms(client: AsyncClient): response = await client.get(f"/officers/terms/{load_test_db.SYSADMIN_COMPUTING_ID}?include_future_terms=false") @@ -113,63 +117,77 @@ async def test__get_officer_terms(client: AsyncClient): assert len(response.json()) == 0 response = await client.get("/officers/terms/abc11?include_future_terms=true") - assert response.status_code == 401 + assert response.status_code == status.HTTP_403_FORBIDDEN response = await client.get("/officers/info/abc11") - assert response.status_code == 401 + assert response.status_code == status.HTTP_401_UNAUTHORIZED response = await client.get(f"/officers/info/{load_test_db.SYSADMIN_COMPUTING_ID}") - assert response.status_code == 401 - -async def test__post_officer_terms(client: AsyncClient): - # Only admins can create new terms - response = await client.post("officers/term", json=[{ - "computing_id": "ehbc12", - "position": OfficerPositionEnum.DIRECTOR_OF_MULTIMEDIA, - "start_date": "2025-12-29", - "legal_name": "Eh Bc" - }]) - assert response.status_code == 401 + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + +async def test__user_create_officer_term(client: AsyncClient): + response = await client.post( + "officers/term", + json=[ + { + "computing_id": "ehbc12", + "position": OfficerPositionEnum.DIRECTOR_OF_MULTIMEDIA, + "start_date": "2025-12-29", + "legal_name": "Eh Bc", + } + ], + ) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + +async def test__create_officer_term_bad_enum(client: AsyncClient): # Position must be one of the enum positions - response = await client.post("officers/term", json=[{ - "computing_id": "ehbc12", - "position": "balargho", - "start_date": "2025-12-29", - "legal_name": "Eh Bc" - }]) - assert response.status_code == 422 - -async def test__patch_officer_term(client: AsyncClient): + response = await client.post( + "officers/term", + json=[{"computing_id": "ehbc12", "position": "balargho", "start_date": "2025-12-29", "legal_name": "Eh Bc"}], + ) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + +async def test__update_officer_term(client: AsyncClient): # Only admins can update new terms - response = await client.patch("officers/info/abc11", json={ - "legal_name": "fancy name", - "phone_number": None, - "discord_name": None, - "github_username": None, - "google_drive_email": None, - }) - assert response.status_code == 403 - - response = await client.patch("officers/term/1", content=json.dumps({ - "computing_id": "abc11", - "position": OfficerPositionEnum.VICE_PRESIDENT, - "start_date": (date.today() - timedelta(days=365)).isoformat(), - "end_date": (date.today() - timedelta(days=1)).isoformat(), - - # officer should change: - "nickname": "1", - "favourite_course_0": "2", - "favourite_course_1": "3", - "favourite_pl_0": "4", - "favourite_pl_1": "5", - "biography": "hello" - })) - assert response.status_code == 403 + response = await client.patch( + "officers/info/abc11", + json={ + "legal_name": "fancy name", + "phone_number": None, + "discord_name": None, + "github_username": None, + "google_drive_email": None, + }, + ) + assert response.status_code == status.HTTP_403_FORBIDDEN + + response = await client.patch( + "officers/term/1", + content=json.dumps( + { + "computing_id": "abc11", + "position": OfficerPositionEnum.VICE_PRESIDENT, + "start_date": (date.today() - timedelta(days=365)).isoformat(), + "end_date": (date.today() - timedelta(days=1)).isoformat(), + # officer should change: + "nickname": "1", + "favourite_course_0": "2", + "favourite_course_1": "3", + "favourite_pl_0": "4", + "favourite_pl_1": "5", + "biography": "hello", + } + ), + ) + assert response.status_code == status.HTTP_401_UNAUTHORIZED response = await client.delete("officers/term/1") - assert response.status_code == 401 + assert response.status_code == status.HTTP_401_UNAUTHORIZED + -async def test__get_current_officers_admin(admin_client): +async def test__get_current_officers_admin(admin_client: AsyncClient): # test that more info is given if logged in & with access to it response = await admin_client.get("/officers/current") assert response.status_code == 200 @@ -177,28 +195,37 @@ async def test__get_current_officers_admin(admin_client): assert len(curr_officers) == 3 assert curr_officers["executive at large"]["computing_id"] is not None -async def test__get_all_officers_admin(admin_client): + +async def test__get_all_officers_admin(admin_client: AsyncClient): response = await admin_client.get("/officers/all?include_future_terms=true") assert response.status_code == 200 assert len(response.json()) == 9 assert response.json()[1]["phone_number"] == "1234567890" -async def test__get_officer_term_admin(admin_client): - response = await admin_client.get(f"/officers/terms/{load_test_db.SYSADMIN_COMPUTING_ID}?include_future_terms=false") + +async def test__admin_get_officer_term(admin_client: AsyncClient): + response = await admin_client.get( + f"/officers/terms/{load_test_db.SYSADMIN_COMPUTING_ID}?include_future_terms=false" + ) assert response.status_code == 200 assert response.json() != [] assert len(response.json()) == 2 + +async def test__admin_get_officer_term_with_future(admin_client: AsyncClient): response = await admin_client.get(f"/officers/terms/{load_test_db.SYSADMIN_COMPUTING_ID}?include_future_terms=true") assert response.status_code == 200 assert response.json() != [] assert len(response.json()) == 3 + +async def test__admin_get_other_officer_term_with_future(admin_client: AsyncClient): response = await admin_client.get("/officers/terms/ehbc12?include_future_terms=true") assert response.status_code == 200 assert response.json() == [] -async def test__get_officer_info_admin(admin_client): + +async def test__get_single_valid_officer_info(admin_client: AsyncClient): response = await admin_client.get("/officers/info/abc11") assert response.status_code == 200 assert response.json() != {} @@ -209,13 +236,19 @@ async def test__get_officer_info_admin(admin_client): response = await admin_client.get("/officers/info/balargho") assert response.status_code == 404 -async def test__post_officer_term_admin(admin_client): - response = await admin_client.post("officers/term", json=[{ - "computing_id": "ehbc12", - "position": OfficerPositionEnum.DIRECTOR_OF_MULTIMEDIA, - "start_date": "2025-12-29", - "legal_name": "Eh Bc" - }]) + +async def test__admin_create_officer_term(admin_client: AsyncClient): + response = await admin_client.post( + "officers/term", + json=[ + { + "computing_id": "ehbc12", + "position": OfficerPositionEnum.DIRECTOR_OF_MULTIMEDIA, + "start_date": "2026-12-29", + "legal_name": "Eh Bc", + } + ], + ) assert response.status_code == 200 response = await admin_client.get("/officers/terms/ehbc12?include_future_terms=true") @@ -223,14 +256,20 @@ async def test__post_officer_term_admin(admin_client): assert response.json() != [] assert len(response.json()) == 1 -async def test__patch_officer_info_admin(admin_client): - response = await admin_client.patch("officers/info/abc11", content=json.dumps({ - "legal_name": "Person A2", - "phone_number": "12345asdab67890", - "discord_name": "person_a_yeah", - "github_username": "person_a", - "google_drive_email": "person_a@gmail.com", - })) + +async def test__admin_patch_officer_info(admin_client: AsyncClient): + response = await admin_client.patch( + "officers/info/abc11", + content=json.dumps( + { + "legal_name": "Person A2", + "phone_number": "12345asdab67890", + "discord_name": "person_a_yeah", + "github_username": "person_a", + "google_drive_email": "person_a@gmail.com", + } + ), + ) assert response.status_code == 200 resJson = response.json() assert resJson["legal_name"] == "Person A2" @@ -239,34 +278,42 @@ async def test__patch_officer_info_admin(admin_client): assert resJson["github_username"] == "person_a" assert resJson["google_drive_email"] == "person_a@gmail.com" - response = await admin_client.patch("officers/info/aaabbbc", content=json.dumps({ - "legal_name": "Person AABBCC", - "phone_number": "1234567890", - "discord_name": None, - "github_username": None, - "google_drive_email": "person_aaa_bbb_ccc+spam@gmail.com", - })) + response = await admin_client.patch( + "officers/info/aaabbbc", + content=json.dumps( + { + "legal_name": "Person AABBCC", + "phone_number": "1234567890", + "discord_name": None, + "github_username": None, + "google_drive_email": "person_aaa_bbb_ccc+spam@gmail.com", + } + ), + ) assert response.status_code == 404 -async def test__patch_officer_term_admin(admin_client): + +async def test__admin_patch_officer_term(admin_client: AsyncClient): target_id = 1 - response = await admin_client.patch(f"officers/term/{target_id}", json={ - "position": OfficerPositionEnum.TREASURER, - "start_date": (date.today() - timedelta(days=365)).isoformat(), - "end_date": (date.today() - timedelta(days=1)).isoformat(), - "nickname": "1", - "favourite_course_0": "2", - "favourite_course_1": "3", - "favourite_pl_0": "4", - "favourite_pl_1": "5", - "biography": "hello o77" - }) + response = await admin_client.patch( + f"officers/term/{target_id}", + json={ + "position": OfficerPositionEnum.TREASURER, + "start_date": (date.today() - timedelta(days=365)).isoformat(), + "end_date": (date.today() - timedelta(days=1)).isoformat(), + "nickname": "1", + "favourite_course_0": "2", + "favourite_course_1": "3", + "favourite_pl_0": "4", + "favourite_pl_1": "5", + "biography": "hello o77", + }, + ) assert response.status_code == 200 response = await admin_client.get("/officers/terms/abc11?include_future_terms=true") assert response.status_code == 200 modifiedTerm = next((item for item in response.json() if item["id"] == target_id), None) - print(modifiedTerm) assert modifiedTerm is not None assert modifiedTerm["position"] == OfficerPositionEnum.TREASURER assert modifiedTerm["start_date"] == (date.today() - timedelta(days=365)).isoformat() @@ -281,7 +328,6 @@ async def test__patch_officer_term_admin(admin_client): # other one shouldn't be modified assert response.status_code == 200 modifiedTerm = next((item for item in response.json() if item["id"] == target_id + 1), None) - print(modifiedTerm) assert modifiedTerm is not None assert modifiedTerm["position"] == OfficerPositionEnum.EXECUTIVE_AT_LARGE assert modifiedTerm["start_date"] != (date.today() - timedelta(days=365)).isoformat() diff --git a/uv.lock b/uv.lock new file mode 100644 index 00000000..31fbb16a --- /dev/null +++ b/uv.lock @@ -0,0 +1,897 @@ +version = 1 +revision = 3 +requires-python = "==3.11.*" + +[[package]] +name = "alembic" +version = "1.13.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mako" }, + { name = "sqlalchemy" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7b/24/ddce068e2ac9b5581bd58602edb2a1be1b0752e1ff2963c696ecdbe0470d/alembic-1.13.1.tar.gz", hash = "sha256:4932c8558bf68f2ee92b9bbcb8218671c627064d5b08939437af6d77dc05e595", size = 1213288, upload-time = "2023-12-20T17:06:14.195Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/50/9fb3a5c80df6eb6516693270621676980acd6d5a9a7efdbfa273f8d616c7/alembic-1.13.1-py3-none-any.whl", hash = "sha256:2edcc97bed0bd3272611ce3a98d98279e9c209e7186e43e75bbb1b2bdfdbcc43", size = 233424, upload-time = "2023-12-20T17:06:16.839Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/16/ce/8a777047513153587e5434fd752e89334ac33e379aa3497db860eeb60377/anyio-4.12.0.tar.gz", hash = "sha256:73c693b567b0c55130c104d0b43a9baf3aa6a31fc6110116509f27bf75e21ec0", size = 228266, upload-time = "2025-11-28T23:37:38.911Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/9c/36c5c37947ebfb8c7f22e0eb6e4d188ee2d53aa3880f3f2744fb894f0cb1/anyio-4.12.0-py3-none-any.whl", hash = "sha256:dad2376a628f98eeca4881fc56cd06affd18f659b17a747d3ff0307ced94b1bb", size = 113362, upload-time = "2025-11-28T23:36:57.897Z" }, +] + +[[package]] +name = "async-timeout" +version = "5.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3", size = 9274, upload-time = "2024-11-06T16:41:39.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233, upload-time = "2024-11-06T16:41:37.9Z" }, +] + +[[package]] +name = "asyncpg" +version = "0.29.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "async-timeout" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c1/11/7a6000244eaeb6b8ed2238bf33477c486515d6133f2c295913aca3ba4a00/asyncpg-0.29.0.tar.gz", hash = "sha256:d1c49e1f44fffafd9a55e1a9b101590859d881d639ea2922516f5d9c512d354e", size = 820455, upload-time = "2023-11-05T05:59:10.879Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/28/3e3c4e243778f0361214b9d6e8bc6aa8e8bf55f35a2d2cb8949a6863caab/asyncpg-0.29.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d4900ee08e85af01adb207519bb4e14b1cae8fd21e0ccf80fac6aa60b6da37b4", size = 653061, upload-time = "2023-11-05T05:58:00.147Z" }, + { url = "https://files.pythonhosted.org/packages/4a/13/f96284d7014dd06db2e78bea15706443d7895548bf74cf34f0c3ee1863fd/asyncpg-0.29.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a65c1dcd820d5aea7c7d82a3fdcb70e096f8f70d1a8bf93eb458e49bfad036ac", size = 638740, upload-time = "2023-11-05T05:58:02.438Z" }, + { url = "https://files.pythonhosted.org/packages/27/25/d140bd503932f99528edc0a1461648973ad3c1c67f5929d11f3e8b5f81f4/asyncpg-0.29.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5b52e46f165585fd6af4863f268566668407c76b2c72d366bb8b522fa66f1870", size = 2788952, upload-time = "2023-11-05T05:58:04.895Z" }, + { url = "https://files.pythonhosted.org/packages/c4/41/a0bdc18f13bdd5f27e7fc1b5de7e1caae19951967c109bca1a2e99cf3331/asyncpg-0.29.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc600ee8ef3dd38b8d67421359779f8ccec30b463e7aec7ed481c8346decf99f", size = 2809108, upload-time = "2023-11-05T05:58:07.021Z" }, + { url = "https://files.pythonhosted.org/packages/f2/1f/1737248d7b1b75d19e7f07a98321bc58cb6fc979754c78544cfebff3359b/asyncpg-0.29.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:039a261af4f38f949095e1e780bae84a25ffe3e370175193174eb08d3cecab23", size = 3355924, upload-time = "2023-11-05T05:58:09.676Z" }, + { url = "https://files.pythonhosted.org/packages/88/b0/6bebd69ed484055d47b78ea34fd9887c35694b63c9a648a7f02759d3bf73/asyncpg-0.29.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:6feaf2d8f9138d190e5ec4390c1715c3e87b37715cd69b2c3dfca616134efd2b", size = 3391360, upload-time = "2023-11-05T05:58:12.203Z" }, + { url = "https://files.pythonhosted.org/packages/5b/89/3ed6e9d235f8aa13aa8ee8dc3a70f754962dbd441bec2dcfdae9f9e0e2e3/asyncpg-0.29.0-cp311-cp311-win32.whl", hash = "sha256:1e186427c88225ef730555f5fdda6c1812daa884064bfe6bc462fd3a71c4b675", size = 496216, upload-time = "2023-11-05T05:58:14.483Z" }, + { url = "https://files.pythonhosted.org/packages/f2/39/f7e755b5d5aa59d8385c08be58726aceffc1da9360041031554d664c783f/asyncpg-0.29.0-cp311-cp311-win_amd64.whl", hash = "sha256:cfe73ffae35f518cfd6e4e5f5abb2618ceb5ef02a2365ce64f132601000587d3", size = 543321, upload-time = "2023-11-05T05:58:16.329Z" }, +] + +[[package]] +name = "cachetools" +version = "6.2.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bc/1d/ede8680603f6016887c062a2cf4fc8fdba905866a3ab8831aa8aa651320c/cachetools-6.2.4.tar.gz", hash = "sha256:82c5c05585e70b6ba2d3ae09ea60b79548872185d2f24ae1f2709d37299fd607", size = 31731, upload-time = "2025-12-15T18:24:53.744Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/fc/1d7b80d0eb7b714984ce40efc78859c022cd930e402f599d8ca9e39c78a4/cachetools-6.2.4-py3-none-any.whl", hash = "sha256:69a7a52634fed8b8bf6e24a050fb60bff1c9bd8f6d24572b99c32d4e71e62a51", size = 11551, upload-time = "2025-12-15T18:24:52.332Z" }, +] + +[[package]] +name = "certifi" +version = "2025.11.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/8c/58f469717fa48465e4a50c014a0400602d3c437d7c0c468e17ada824da3a/certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316", size = 160538, upload-time = "2025-11-12T02:54:51.517Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438, upload-time = "2025-11-12T02:54:49.735Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, + { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, + { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, + { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, + { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/27/c6491ff4954e58a10f69ad90aca8a1b6fe9c5d3c6f380907af3c37435b59/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", size = 206988, upload-time = "2025-10-14T04:40:33.79Z" }, + { url = "https://files.pythonhosted.org/packages/94/59/2e87300fe67ab820b5428580a53cad894272dbb97f38a7a814a2a1ac1011/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", size = 147324, upload-time = "2025-10-14T04:40:34.961Z" }, + { url = "https://files.pythonhosted.org/packages/07/fb/0cf61dc84b2b088391830f6274cb57c82e4da8bbc2efeac8c025edb88772/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", size = 142742, upload-time = "2025-10-14T04:40:36.105Z" }, + { url = "https://files.pythonhosted.org/packages/62/8b/171935adf2312cd745d290ed93cf16cf0dfe320863ab7cbeeae1dcd6535f/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc", size = 160863, upload-time = "2025-10-14T04:40:37.188Z" }, + { url = "https://files.pythonhosted.org/packages/09/73/ad875b192bda14f2173bfc1bc9a55e009808484a4b256748d931b6948442/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897", size = 157837, upload-time = "2025-10-14T04:40:38.435Z" }, + { url = "https://files.pythonhosted.org/packages/6d/fc/de9cce525b2c5b94b47c70a4b4fb19f871b24995c728e957ee68ab1671ea/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381", size = 151550, upload-time = "2025-10-14T04:40:40.053Z" }, + { url = "https://files.pythonhosted.org/packages/55/c2/43edd615fdfba8c6f2dfbd459b25a6b3b551f24ea21981e23fb768503ce1/charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815", size = 149162, upload-time = "2025-10-14T04:40:41.163Z" }, + { url = "https://files.pythonhosted.org/packages/03/86/bde4ad8b4d0e9429a4e82c1e8f5c659993a9a863ad62c7df05cf7b678d75/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0", size = 150019, upload-time = "2025-10-14T04:40:42.276Z" }, + { url = "https://files.pythonhosted.org/packages/1f/86/a151eb2af293a7e7bac3a739b81072585ce36ccfb4493039f49f1d3cae8c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161", size = 143310, upload-time = "2025-10-14T04:40:43.439Z" }, + { url = "https://files.pythonhosted.org/packages/b5/fe/43dae6144a7e07b87478fdfc4dbe9efd5defb0e7ec29f5f58a55aeef7bf7/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4", size = 162022, upload-time = "2025-10-14T04:40:44.547Z" }, + { url = "https://files.pythonhosted.org/packages/80/e6/7aab83774f5d2bca81f42ac58d04caf44f0cc2b65fc6db2b3b2e8a05f3b3/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89", size = 149383, upload-time = "2025-10-14T04:40:46.018Z" }, + { url = "https://files.pythonhosted.org/packages/4f/e8/b289173b4edae05c0dde07f69f8db476a0b511eac556dfe0d6bda3c43384/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569", size = 159098, upload-time = "2025-10-14T04:40:47.081Z" }, + { url = "https://files.pythonhosted.org/packages/d8/df/fe699727754cae3f8478493c7f45f777b17c3ef0600e28abfec8619eb49c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224", size = 152991, upload-time = "2025-10-14T04:40:48.246Z" }, + { url = "https://files.pythonhosted.org/packages/1a/86/584869fe4ddb6ffa3bd9f491b87a01568797fb9bd8933f557dba9771beaf/charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a", size = 99456, upload-time = "2025-10-14T04:40:49.376Z" }, + { url = "https://files.pythonhosted.org/packages/65/f6/62fdd5feb60530f50f7e38b4f6a1d5203f4d16ff4f9f0952962c044e919a/charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016", size = 106978, upload-time = "2025-10-14T04:40:50.844Z" }, + { url = "https://files.pythonhosted.org/packages/7a/9d/0710916e6c82948b3be62d9d398cb4fcf4e97b56d6a6aeccd66c4b2f2bd5/charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1", size = 99969, upload-time = "2025-10-14T04:40:52.272Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, +] + +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "cryptography" +version = "42.0.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/93/a7/1498799a2ea06148463a9a2c10ab2f6a921a74fb19e231b27dc412a748e2/cryptography-42.0.8.tar.gz", hash = "sha256:8d09d05439ce7baa8e9e95b07ec5b6c886f548deb7e0f69ef25f64b3bce842f2", size = 671250, upload-time = "2024-06-04T19:55:08.609Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/8b/1b929ba8139430e09e140e6939c2b29c18df1f2fc2149e41bdbdcdaf5d1f/cryptography-42.0.8-cp37-abi3-macosx_10_12_universal2.whl", hash = "sha256:81d8a521705787afe7a18d5bfb47ea9d9cc068206270aad0b96a725022e18d2e", size = 5899961, upload-time = "2024-06-04T19:53:57.933Z" }, + { url = "https://files.pythonhosted.org/packages/fa/5d/31d833daa800e4fab33209843095df7adb4a78ea536929145534cbc15026/cryptography-42.0.8-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:961e61cefdcb06e0c6d7e3a1b22ebe8b996eb2bf50614e89384be54c48c6b63d", size = 3114353, upload-time = "2024-06-04T19:54:12.171Z" }, + { url = "https://files.pythonhosted.org/packages/5d/32/f6326c70a9f0f258a201d3b2632bca586ea24d214cec3cf36e374040e273/cryptography-42.0.8-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e3ec3672626e1b9e55afd0df6d774ff0e953452886e06e0f1eb7eb0c832e8902", size = 3647773, upload-time = "2024-06-04T19:54:07.051Z" }, + { url = "https://files.pythonhosted.org/packages/35/66/2d87e9ca95c82c7ee5f2c09716fc4c4242c1ae6647b9bd27e55e920e9f10/cryptography-42.0.8-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e599b53fd95357d92304510fb7bda8523ed1f79ca98dce2f43c115950aa78801", size = 3839763, upload-time = "2024-06-04T19:54:30.383Z" }, + { url = "https://files.pythonhosted.org/packages/c2/de/8083fa2e68d403553a01a9323f4f8b9d7ffed09928ba25635c29fb28c1e7/cryptography-42.0.8-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5226d5d21ab681f432a9c1cf8b658c0cb02533eece706b155e5fbd8a0cdd3949", size = 3632661, upload-time = "2024-06-04T19:54:32.955Z" }, + { url = "https://files.pythonhosted.org/packages/07/40/d6f6819c62e808ea74639c3c640f7edd636b86cce62cb14943996a15df92/cryptography-42.0.8-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6b7c4f03ce01afd3b76cf69a5455caa9cfa3de8c8f493e0d3ab7d20611c8dae9", size = 3851536, upload-time = "2024-06-04T19:53:53.131Z" }, + { url = "https://files.pythonhosted.org/packages/5c/46/de71d48abf2b6d3c808f4fbb0f4dc44a4e72786be23df0541aa2a3f6fd7e/cryptography-42.0.8-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:2346b911eb349ab547076f47f2e035fc8ff2c02380a7cbbf8d87114fa0f1c583", size = 3754209, upload-time = "2024-06-04T19:54:55.259Z" }, + { url = "https://files.pythonhosted.org/packages/25/c9/86f04e150c5d5d5e4a731a2c1e0e43da84d901f388e3fea3d5de98d689a7/cryptography-42.0.8-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:ad803773e9df0b92e0a817d22fd8a3675493f690b96130a5e24f1b8fabbea9c7", size = 3923551, upload-time = "2024-06-04T19:54:16.46Z" }, + { url = "https://files.pythonhosted.org/packages/53/c2/903014dafb7271fb148887d4355b2e90319cad6e810663be622b0c933fc9/cryptography-42.0.8-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2f66d9cd9147ee495a8374a45ca445819f8929a3efcd2e3df6428e46c3cbb10b", size = 3739265, upload-time = "2024-06-04T19:54:23.194Z" }, + { url = "https://files.pythonhosted.org/packages/95/26/82d704d988a193cbdc69ac3b41c687c36eaed1642cce52530ad810c35645/cryptography-42.0.8-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:d45b940883a03e19e944456a558b67a41160e367a719833c53de6911cabba2b7", size = 3937371, upload-time = "2024-06-04T19:55:04.303Z" }, + { url = "https://files.pythonhosted.org/packages/cf/71/4e0d05c9acd638a225f57fb6162aa3d03613c11b76893c23ea4675bb28c5/cryptography-42.0.8-cp37-abi3-win32.whl", hash = "sha256:a0c5b2b0585b6af82d7e385f55a8bc568abff8923af147ee3c07bd8b42cda8b2", size = 2438849, upload-time = "2024-06-04T19:54:27.39Z" }, + { url = "https://files.pythonhosted.org/packages/06/0f/78da3cad74f2ba6c45321dc90394d70420ea846730dc042ef527f5a224b5/cryptography-42.0.8-cp37-abi3-win_amd64.whl", hash = "sha256:57080dee41209e556a9a4ce60d229244f7a66ef52750f813bfbe18959770cfba", size = 2889090, upload-time = "2024-06-04T19:54:14.245Z" }, + { url = "https://files.pythonhosted.org/packages/60/12/f064af29190cdb1d38fe07f3db6126091639e1dece7ec77c4ff037d49193/cryptography-42.0.8-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:dea567d1b0e8bc5764b9443858b673b734100c2871dc93163f58c46a97a83d28", size = 5901232, upload-time = "2024-06-04T19:54:52.722Z" }, + { url = "https://files.pythonhosted.org/packages/43/c2/4a3eef67e009a522711ebd8ac89424c3a7fe591ece7035d964419ad52a1d/cryptography-42.0.8-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c4783183f7cb757b73b2ae9aed6599b96338eb957233c58ca8f49a49cc32fd5e", size = 3648711, upload-time = "2024-06-04T19:54:44.323Z" }, + { url = "https://files.pythonhosted.org/packages/49/1c/9f6d13cc8041c05eebff1154e4e71bedd1db8e174fff999054435994187a/cryptography-42.0.8-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a0608251135d0e03111152e41f0cc2392d1e74e35703960d4190b2e0f4ca9c70", size = 3841968, upload-time = "2024-06-04T19:54:57.911Z" }, + { url = "https://files.pythonhosted.org/packages/5f/f9/c3d4f19b82bdb25a3d857fe96e7e571c981810e47e3f299cc13ac429066a/cryptography-42.0.8-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:dc0fdf6787f37b1c6b08e6dfc892d9d068b5bdb671198c72072828b80bd5fe4c", size = 3633032, upload-time = "2024-06-04T19:54:48.518Z" }, + { url = "https://files.pythonhosted.org/packages/fa/e2/b7e6e8c261536c489d9cf908769880d94bd5d9a187e166b0dc838d2e6a56/cryptography-42.0.8-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:9c0c1716c8447ee7dbf08d6db2e5c41c688544c61074b54fc4564196f55c25a7", size = 3852478, upload-time = "2024-06-04T19:54:50.599Z" }, + { url = "https://files.pythonhosted.org/packages/a2/68/e16751f6b859bc120f53fddbf3ebada5c34f0e9689d8af32884d8b2e4b4c/cryptography-42.0.8-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:fff12c88a672ab9c9c1cf7b0c80e3ad9e2ebd9d828d955c126be4fd3e5578c9e", size = 3754102, upload-time = "2024-06-04T19:54:46.231Z" }, + { url = "https://files.pythonhosted.org/packages/0f/38/85c74d0ac4c540780e072b1e6f148ecb718418c1062edcb20d22f3ec5bbb/cryptography-42.0.8-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:cafb92b2bc622cd1aa6a1dce4b93307792633f4c5fe1f46c6b97cf67073ec961", size = 3925042, upload-time = "2024-06-04T19:54:34.767Z" }, + { url = "https://files.pythonhosted.org/packages/89/f4/a8b982e88eb5350407ebdbf4717b55043271d878705329e107f4783555f2/cryptography-42.0.8-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:31f721658a29331f895a5a54e7e82075554ccfb8b163a18719d342f5ffe5ecb1", size = 3738833, upload-time = "2024-06-04T19:54:05.231Z" }, + { url = "https://files.pythonhosted.org/packages/fd/2b/be327b580645927bb1a1f32d5a175b897a9b956bc085b095e15c40bac9ed/cryptography-42.0.8-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b297f90c5723d04bcc8265fc2a0f86d4ea2e0f7ab4b6994459548d3a6b992a14", size = 3938751, upload-time = "2024-06-04T19:54:37.837Z" }, + { url = "https://files.pythonhosted.org/packages/3c/d5/c6a78ffccdbe4516711ebaa9ed2c7eb6ac5dfa3dc920f2c7e920af2418b0/cryptography-42.0.8-cp39-abi3-win32.whl", hash = "sha256:2f88d197e66c65be5e42cd72e5c18afbfae3f741742070e3019ac8f4ac57262c", size = 2439281, upload-time = "2024-06-04T19:53:55.903Z" }, + { url = "https://files.pythonhosted.org/packages/a2/7b/b0d330852dd5953daee6b15f742f15d9f18e9c0154eb4cfcc8718f0436da/cryptography-42.0.8-cp39-abi3-win_amd64.whl", hash = "sha256:fa76fbb7596cc5839320000cdd5d0955313696d9511debab7ee7278fc8b5c84a", size = 2886038, upload-time = "2024-06-04T19:54:18.707Z" }, +] + +[[package]] +name = "csss-site-backend" +version = "0.1" +source = { virtual = "." } +dependencies = [ + { name = "alembic" }, + { name = "asyncpg" }, + { name = "fastapi" }, + { name = "google-api-python-client" }, + { name = "gunicorn" }, + { name = "pyopenssl" }, + { name = "requests" }, + { name = "sqlalchemy", extra = ["asyncio"] }, + { name = "uvicorn", extra = ["standard"] }, + { name = "xmltodict" }, +] + +[package.optional-dependencies] +dev = [ + { name = "ruff" }, +] +test = [ + { name = "httpx" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, +] + +[package.metadata] +requires-dist = [ + { name = "alembic", specifier = "==1.13.1" }, + { name = "asyncpg", specifier = "==0.29.0" }, + { name = "fastapi", specifier = "==0.115.6" }, + { name = "google-api-python-client", specifier = "==2.143.0" }, + { name = "gunicorn", specifier = "==21.2.0" }, + { name = "httpx", marker = "extra == 'test'" }, + { name = "pyopenssl", specifier = "==24.0.0" }, + { name = "pytest", marker = "extra == 'test'" }, + { name = "pytest-asyncio", marker = "extra == 'test'" }, + { name = "requests", specifier = "==2.31.0" }, + { name = "ruff", marker = "extra == 'dev'", specifier = "==0.6.9" }, + { name = "sqlalchemy", extras = ["asyncio"], specifier = "==2.0.27" }, + { name = "uvicorn", extras = ["standard"], specifier = "==0.27.1" }, + { name = "xmltodict", specifier = "==0.13.0" }, +] +provides-extras = ["dev", "test"] + +[[package]] +name = "fastapi" +version = "0.115.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/93/72/d83b98cd106541e8f5e5bfab8ef2974ab45a62e8a6c5b5e6940f26d2ed4b/fastapi-0.115.6.tar.gz", hash = "sha256:9ec46f7addc14ea472958a96aae5b5de65f39721a46aaf5705c480d9a8b76654", size = 301336, upload-time = "2024-12-03T22:46:01.629Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/b3/7e4df40e585df024fac2f80d1a2d579c854ac37109675db2b0cc22c0bb9e/fastapi-0.115.6-py3-none-any.whl", hash = "sha256:e9240b29e36fa8f4bb7290316988e90c381e5092e0cbe84e7818cc3713bcf305", size = 94843, upload-time = "2024-12-03T22:45:59.368Z" }, +] + +[[package]] +name = "google-api-core" +version = "2.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-auth" }, + { name = "googleapis-common-protos" }, + { name = "proto-plus" }, + { name = "protobuf" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/da/83d7043169ac2c8c7469f0e375610d78ae2160134bf1b80634c482fa079c/google_api_core-2.28.1.tar.gz", hash = "sha256:2b405df02d68e68ce0fbc138559e6036559e685159d148ae5861013dc201baf8", size = 176759, upload-time = "2025-10-28T21:34:51.529Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/d4/90197b416cb61cefd316964fd9e7bd8324bcbafabf40eef14a9f20b81974/google_api_core-2.28.1-py3-none-any.whl", hash = "sha256:4021b0f8ceb77a6fb4de6fde4502cecab45062e66ff4f2895169e0b35bc9466c", size = 173706, upload-time = "2025-10-28T21:34:50.151Z" }, +] + +[[package]] +name = "google-api-python-client" +version = "2.143.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-api-core" }, + { name = "google-auth" }, + { name = "google-auth-httplib2" }, + { name = "httplib2" }, + { name = "uritemplate" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/99/c2/efec3de62b53d3ac9709aa4f4e1c475041e973578e0c448fb76355b72c27/google_api_python_client-2.143.0.tar.gz", hash = "sha256:6a75441f9078e6e2fcdf4946a153fda1e2cc81b5e9c8d6e8c0750c85c7f8a566", size = 11695158, upload-time = "2024-08-28T19:35:09.96Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/72/7d5c1ccbc0512a2163aa35c3b877371072723a8c360af8cf0b4a06ec3fad/google_api_python_client-2.143.0-py2.py3-none-any.whl", hash = "sha256:d5654134522b9b574b82234e96f7e0aeeabcbf33643fbabcd449ef0068e3a476", size = 12200034, upload-time = "2024-08-28T19:35:05.941Z" }, +] + +[[package]] +name = "google-auth" +version = "2.45.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cachetools" }, + { name = "pyasn1-modules" }, + { name = "rsa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e5/00/3c794502a8b892c404b2dea5b3650eb21bfc7069612fbfd15c7f17c1cb0d/google_auth-2.45.0.tar.gz", hash = "sha256:90d3f41b6b72ea72dd9811e765699ee491ab24139f34ebf1ca2b9cc0c38708f3", size = 320708, upload-time = "2025-12-15T22:58:42.889Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/97/451d55e05487a5cd6279a01a7e34921858b16f7dc8aa38a2c684743cd2b3/google_auth-2.45.0-py2.py3-none-any.whl", hash = "sha256:82344e86dc00410ef5382d99be677c6043d72e502b625aa4f4afa0bdacca0f36", size = 233312, upload-time = "2025-12-15T22:58:40.777Z" }, +] + +[[package]] +name = "google-auth-httplib2" +version = "0.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-auth" }, + { name = "httplib2" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d5/ad/c1f2b1175096a8d04cf202ad5ea6065f108d26be6fc7215876bde4a7981d/google_auth_httplib2-0.3.0.tar.gz", hash = "sha256:177898a0175252480d5ed916aeea183c2df87c1f9c26705d74ae6b951c268b0b", size = 11134, upload-time = "2025-12-15T22:13:51.825Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/d5/3c97526c8796d3caf5f4b3bed2b05e8a7102326f00a334e7a438237f3b22/google_auth_httplib2-0.3.0-py3-none-any.whl", hash = "sha256:426167e5df066e3f5a0fc7ea18768c08e7296046594ce4c8c409c2457dd1f776", size = 9529, upload-time = "2025-12-15T22:13:51.048Z" }, +] + +[[package]] +name = "googleapis-common-protos" +version = "1.72.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e5/7b/adfd75544c415c487b33061fe7ae526165241c1ea133f9a9125a56b39fd8/googleapis_common_protos-1.72.0.tar.gz", hash = "sha256:e55a601c1b32b52d7a3e65f43563e2aa61bcd737998ee672ac9b951cd49319f5", size = 147433, upload-time = "2025-11-06T18:29:24.087Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/ab/09169d5a4612a5f92490806649ac8d41e3ec9129c636754575b3553f4ea4/googleapis_common_protos-1.72.0-py3-none-any.whl", hash = "sha256:4299c5a82d5ae1a9702ada957347726b167f9f8d1fc352477702a1e851ff4038", size = 297515, upload-time = "2025-11-06T18:29:13.14Z" }, +] + +[[package]] +name = "greenlet" +version = "3.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/e5/40dbda2736893e3e53d25838e0f19a2b417dfc122b9989c91918db30b5d3/greenlet-3.3.0.tar.gz", hash = "sha256:a82bb225a4e9e4d653dd2fb7b8b2d36e4fb25bc0165422a11e48b88e9e6f78fb", size = 190651, upload-time = "2025-12-04T14:49:44.05Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/cb/48e964c452ca2b92175a9b2dca037a553036cb053ba69e284650ce755f13/greenlet-3.3.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:e29f3018580e8412d6aaf5641bb7745d38c85228dacf51a73bd4e26ddf2a6a8e", size = 274908, upload-time = "2025-12-04T14:23:26.435Z" }, + { url = "https://files.pythonhosted.org/packages/28/da/38d7bff4d0277b594ec557f479d65272a893f1f2a716cad91efeb8680953/greenlet-3.3.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a687205fb22794e838f947e2194c0566d3812966b41c78709554aa883183fb62", size = 577113, upload-time = "2025-12-04T14:50:05.493Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f2/89c5eb0faddc3ff014f1c04467d67dee0d1d334ab81fadbf3744847f8a8a/greenlet-3.3.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4243050a88ba61842186cb9e63c7dfa677ec146160b0efd73b855a3d9c7fcf32", size = 590338, upload-time = "2025-12-04T14:57:41.136Z" }, + { url = "https://files.pythonhosted.org/packages/80/d7/db0a5085035d05134f8c089643da2b44cc9b80647c39e93129c5ef170d8f/greenlet-3.3.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:670d0f94cd302d81796e37299bcd04b95d62403883b24225c6b5271466612f45", size = 601098, upload-time = "2025-12-04T15:07:11.898Z" }, + { url = "https://files.pythonhosted.org/packages/dc/a6/e959a127b630a58e23529972dbc868c107f9d583b5a9f878fb858c46bc1a/greenlet-3.3.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6cb3a8ec3db4a3b0eb8a3c25436c2d49e3505821802074969db017b87bc6a948", size = 590206, upload-time = "2025-12-04T14:26:01.254Z" }, + { url = "https://files.pythonhosted.org/packages/48/60/29035719feb91798693023608447283b266b12efc576ed013dd9442364bb/greenlet-3.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2de5a0b09eab81fc6a382791b995b1ccf2b172a9fec934747a7a23d2ff291794", size = 1550668, upload-time = "2025-12-04T15:04:22.439Z" }, + { url = "https://files.pythonhosted.org/packages/0a/5f/783a23754b691bfa86bd72c3033aa107490deac9b2ef190837b860996c9f/greenlet-3.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4449a736606bd30f27f8e1ff4678ee193bc47f6ca810d705981cfffd6ce0d8c5", size = 1615483, upload-time = "2025-12-04T14:27:28.083Z" }, + { url = "https://files.pythonhosted.org/packages/1d/d5/c339b3b4bc8198b7caa4f2bd9fd685ac9f29795816d8db112da3d04175bb/greenlet-3.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:7652ee180d16d447a683c04e4c5f6441bae7ba7b17ffd9f6b3aff4605e9e6f71", size = 301164, upload-time = "2025-12-04T14:42:51.577Z" }, +] + +[[package]] +name = "gunicorn" +version = "21.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/89/acd9879fa6a5309b4bf16a5a8855f1e58f26d38e0c18ede9b3a70996b021/gunicorn-21.2.0.tar.gz", hash = "sha256:88ec8bff1d634f98e61b9f65bc4bf3cd918a90806c6f5c48bc5603849ec81033", size = 3632557, upload-time = "2023-07-19T11:46:46.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/2a/c3a878eccb100ccddf45c50b6b8db8cf3301a6adede6e31d48e8531cab13/gunicorn-21.2.0-py3-none-any.whl", hash = "sha256:3213aa5e8c24949e792bcacfc176fef362e7aac80b76c56f6b5122bf350722f0", size = 80176, upload-time = "2023-07-19T11:46:44.51Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httplib2" +version = "0.31.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyparsing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/77/6653db69c1f7ecfe5e3f9726fdadc981794656fcd7d98c4209fecfea9993/httplib2-0.31.0.tar.gz", hash = "sha256:ac7ab497c50975147d4f7b1ade44becc7df2f8954d42b38b3d69c515f531135c", size = 250759, upload-time = "2025-09-11T12:16:03.403Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8c/a2/0d269db0f6163be503775dc8b6a6fa15820cc9fdc866f6ba608d86b721f2/httplib2-0.31.0-py3-none-any.whl", hash = "sha256:b9cd78abea9b4e43a7714c6e0f8b6b8561a6fc1e95d5dbd367f5bf0ef35f5d24", size = 91148, upload-time = "2025-09-11T12:16:01.803Z" }, +] + +[[package]] +name = "httptools" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/08/17e07e8d89ab8f343c134616d72eebfe03798835058e2ab579dcc8353c06/httptools-0.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:474d3b7ab469fefcca3697a10d11a32ee2b9573250206ba1e50d5980910da657", size = 206521, upload-time = "2025-10-10T03:54:31.002Z" }, + { url = "https://files.pythonhosted.org/packages/aa/06/c9c1b41ff52f16aee526fd10fbda99fa4787938aa776858ddc4a1ea825ec/httptools-0.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3c3b7366bb6c7b96bd72d0dbe7f7d5eead261361f013be5f6d9590465ea1c70", size = 110375, upload-time = "2025-10-10T03:54:31.941Z" }, + { url = "https://files.pythonhosted.org/packages/cc/cc/10935db22fda0ee34c76f047590ca0a8bd9de531406a3ccb10a90e12ea21/httptools-0.7.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:379b479408b8747f47f3b253326183d7c009a3936518cdb70db58cffd369d9df", size = 456621, upload-time = "2025-10-10T03:54:33.176Z" }, + { url = "https://files.pythonhosted.org/packages/0e/84/875382b10d271b0c11aa5d414b44f92f8dd53e9b658aec338a79164fa548/httptools-0.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cad6b591a682dcc6cf1397c3900527f9affef1e55a06c4547264796bbd17cf5e", size = 454954, upload-time = "2025-10-10T03:54:34.226Z" }, + { url = "https://files.pythonhosted.org/packages/30/e1/44f89b280f7e46c0b1b2ccee5737d46b3bb13136383958f20b580a821ca0/httptools-0.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:eb844698d11433d2139bbeeb56499102143beb582bd6c194e3ba69c22f25c274", size = 440175, upload-time = "2025-10-10T03:54:35.942Z" }, + { url = "https://files.pythonhosted.org/packages/6f/7e/b9287763159e700e335028bc1824359dc736fa9b829dacedace91a39b37e/httptools-0.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f65744d7a8bdb4bda5e1fa23e4ba16832860606fcc09d674d56e425e991539ec", size = 440310, upload-time = "2025-10-10T03:54:37.1Z" }, + { url = "https://files.pythonhosted.org/packages/b3/07/5b614f592868e07f5c94b1f301b5e14a21df4e8076215a3bccb830a687d8/httptools-0.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:135fbe974b3718eada677229312e97f3b31f8a9c8ffa3ae6f565bf808d5b6bcb", size = 86875, upload-time = "2025-10-10T03:54:38.421Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "mako" +version = "1.3.10" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/38/bd5b78a920a64d708fe6bc8e0a2c075e1389d53bef8413725c63ba041535/mako-1.3.10.tar.gz", hash = "sha256:99579a6f39583fa7e5630a28c3c1f440e4e97a414b80372649c0ce338da2ea28", size = 392474, upload-time = "2025-04-10T12:44:31.16Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/fb/99f81ac72ae23375f22b7afdb7642aba97c00a713c217124420147681a2f/mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59", size = 78509, upload-time = "2025-04-10T12:50:53.297Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "proto-plus" +version = "1.27.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/01/89/9cbe2f4bba860e149108b683bc2efec21f14d5f7ed6e25562ad86acbc373/proto_plus-1.27.0.tar.gz", hash = "sha256:873af56dd0d7e91836aee871e5799e1c6f1bda86ac9a983e0bb9f0c266a568c4", size = 56158, upload-time = "2025-12-16T13:46:25.729Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cd/24/3b7a0818484df9c28172857af32c2397b6d8fcd99d9468bd4684f98ebf0a/proto_plus-1.27.0-py3-none-any.whl", hash = "sha256:1baa7f81cf0f8acb8bc1f6d085008ba4171eaf669629d1b6d1673b21ed1c0a82", size = 50205, upload-time = "2025-12-16T13:46:24.76Z" }, +] + +[[package]] +name = "protobuf" +version = "6.33.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/34/44/e49ecff446afeec9d1a66d6bbf9adc21e3c7cea7803a920ca3773379d4f6/protobuf-6.33.2.tar.gz", hash = "sha256:56dc370c91fbb8ac85bc13582c9e373569668a290aa2e66a590c2a0d35ddb9e4", size = 444296, upload-time = "2025-12-06T00:17:53.311Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/91/1e3a34881a88697a7354ffd177e8746e97a722e5e8db101544b47e84afb1/protobuf-6.33.2-cp310-abi3-win32.whl", hash = "sha256:87eb388bd2d0f78febd8f4c8779c79247b26a5befad525008e49a6955787ff3d", size = 425603, upload-time = "2025-12-06T00:17:41.114Z" }, + { url = "https://files.pythonhosted.org/packages/64/20/4d50191997e917ae13ad0a235c8b42d8c1ab9c3e6fd455ca16d416944355/protobuf-6.33.2-cp310-abi3-win_amd64.whl", hash = "sha256:fc2a0e8b05b180e5fc0dd1559fe8ebdae21a27e81ac77728fb6c42b12c7419b4", size = 436930, upload-time = "2025-12-06T00:17:43.278Z" }, + { url = "https://files.pythonhosted.org/packages/b2/ca/7e485da88ba45c920fb3f50ae78de29ab925d9e54ef0de678306abfbb497/protobuf-6.33.2-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:d9b19771ca75935b3a4422957bc518b0cecb978b31d1dd12037b088f6bcc0e43", size = 427621, upload-time = "2025-12-06T00:17:44.445Z" }, + { url = "https://files.pythonhosted.org/packages/7d/4f/f743761e41d3b2b2566748eb76bbff2b43e14d5fcab694f494a16458b05f/protobuf-6.33.2-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:b5d3b5625192214066d99b2b605f5783483575656784de223f00a8d00754fc0e", size = 324460, upload-time = "2025-12-06T00:17:45.678Z" }, + { url = "https://files.pythonhosted.org/packages/b1/fa/26468d00a92824020f6f2090d827078c09c9c587e34cbfd2d0c7911221f8/protobuf-6.33.2-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:8cd7640aee0b7828b6d03ae518b5b4806fdfc1afe8de82f79c3454f8aef29872", size = 339168, upload-time = "2025-12-06T00:17:46.813Z" }, + { url = "https://files.pythonhosted.org/packages/56/13/333b8f421738f149d4fe5e49553bc2a2ab75235486259f689b4b91f96cec/protobuf-6.33.2-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:1f8017c48c07ec5859106533b682260ba3d7c5567b1ca1f24297ce03384d1b4f", size = 323270, upload-time = "2025-12-06T00:17:48.253Z" }, + { url = "https://files.pythonhosted.org/packages/0e/15/4f02896cc3df04fc465010a4c6a0cd89810f54617a32a70ef531ed75d61c/protobuf-6.33.2-py3-none-any.whl", hash = "sha256:7636aad9bb01768870266de5dc009de2d1b936771b38a793f73cbbf279c91c5c", size = 170501, upload-time = "2025-12-06T00:17:52.211Z" }, +] + +[[package]] +name = "pyasn1" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/e9/01f1a64245b89f039897cb0130016d79f77d52669aae6ee7b159a6c4c018/pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034", size = 145322, upload-time = "2024-09-10T22:41:42.55Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/f1/d6a797abb14f6283c0ddff96bbdd46937f64122b8c925cab503dd37f8214/pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629", size = 83135, upload-time = "2024-09-11T16:00:36.122Z" }, +] + +[[package]] +name = "pyasn1-modules" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" }, +] + +[[package]] +name = "pycparser" +version = "2.23" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", size = 173734, upload-time = "2025-09-09T13:23:47.91Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" }, + { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" }, + { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" }, + { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" }, + { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" }, + { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" }, + { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" }, + { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" }, + { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" }, + { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" }, + { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" }, + { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" }, + { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" }, + { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" }, + { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" }, + { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pyopenssl" +version = "24.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/81/022190e5d21344f6110064f6f52bf0c3b9da86e9e5a64fc4a884856a577d/pyOpenSSL-24.0.0.tar.gz", hash = "sha256:6aa33039a93fffa4563e655b61d11364d01264be8ccb49906101e02a334530bf", size = 183238, upload-time = "2024-01-23T01:43:43.103Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/0e/c6656e62d9424d9c9f14b27be27220602f4af1e64b77f2c86340b671d439/pyOpenSSL-24.0.0-py3-none-any.whl", hash = "sha256:ba07553fb6fd6a7a2259adb9b84e12302a9a8a75c44046e8bb5d3e5ee887e3c3", size = 58557, upload-time = "2024-01-23T01:43:41.036Z" }, +] + +[[package]] +name = "pyparsing" +version = "3.2.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/a5/181488fc2b9d093e3972d2a472855aae8a03f000592dbfce716a512b3359/pyparsing-3.2.5.tar.gz", hash = "sha256:2df8d5b7b2802ef88e8d016a2eb9c7aeaa923529cd251ed0fe4608275d4105b6", size = 1099274, upload-time = "2025-09-21T04:11:06.277Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/5e/1aa9a93198c6b64513c9d7752de7422c06402de6600a8767da1524f9570b/pyparsing-3.2.5-py3-none-any.whl", hash = "sha256:e38a4f02064cf41fe6593d328d0512495ad1f3d8a91c4f73fc401b3079a59a5e", size = 113890, upload-time = "2025-09-21T04:11:04.117Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, +] + +[[package]] +name = "requests" +version = "2.31.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/be/10918a2eac4ae9f02f6cfe6414b7a155ccd8f7f9d4380d62fd5b955065c3/requests-2.31.0.tar.gz", hash = "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1", size = 110794, upload-time = "2023-05-22T15:12:44.175Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/8e/0e2d847013cb52cd35b38c009bb167a1a26b2ce6cd6965bf26b47bc0bf44/requests-2.31.0-py3-none-any.whl", hash = "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f", size = 62574, upload-time = "2023-05-22T15:12:42.313Z" }, +] + +[[package]] +name = "rsa" +version = "4.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" }, +] + +[[package]] +name = "ruff" +version = "0.6.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/0d/6148a48dab5662ca1d5a93b7c0d13c03abd3cc7e2f35db08410e47cef15d/ruff-0.6.9.tar.gz", hash = "sha256:b076ef717a8e5bc819514ee1d602bbdca5b4420ae13a9cf61a0c0a4f53a2baa2", size = 3095355, upload-time = "2024-10-04T13:40:28.594Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6e/8f/f7a0a0ef1818662efb32ed6df16078c95da7a0a3248d64c2410c1e27799f/ruff-0.6.9-py3-none-linux_armv6l.whl", hash = "sha256:064df58d84ccc0ac0fcd63bc3090b251d90e2a372558c0f057c3f75ed73e1ccd", size = 10440526, upload-time = "2024-10-04T13:39:21.747Z" }, + { url = "https://files.pythonhosted.org/packages/8b/69/b179a5faf936a9e2ab45bb412a668e4661eded964ccfa19d533f29463ef6/ruff-0.6.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:140d4b5c9f5fc7a7b074908a78ab8d384dd7f6510402267bc76c37195c02a7ec", size = 10034612, upload-time = "2024-10-04T13:39:26.301Z" }, + { url = "https://files.pythonhosted.org/packages/c7/ef/fd1b4be979c579d191eeac37b5cfc0ec906de72c8bcd8595e2c81bb700c1/ruff-0.6.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:53fd8ca5e82bdee8da7f506d7b03a261f24cd43d090ea9db9a1dc59d9313914c", size = 9706197, upload-time = "2024-10-04T13:39:29.297Z" }, + { url = "https://files.pythonhosted.org/packages/29/61/b376d775deb5851cb48d893c568b511a6d3625ef2c129ad5698b64fb523c/ruff-0.6.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:645d7d8761f915e48a00d4ecc3686969761df69fb561dd914a773c1a8266e14e", size = 10751855, upload-time = "2024-10-04T13:39:33.175Z" }, + { url = "https://files.pythonhosted.org/packages/13/d7/def9e5f446d75b9a9c19b24231a3a658c075d79163b08582e56fa5dcfa38/ruff-0.6.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eae02b700763e3847595b9d2891488989cac00214da7f845f4bcf2989007d577", size = 10200889, upload-time = "2024-10-04T13:39:36.867Z" }, + { url = "https://files.pythonhosted.org/packages/6c/d6/7f34160818bcb6e84ce293a5966cba368d9112ff0289b273fbb689046047/ruff-0.6.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7d5ccc9e58112441de8ad4b29dcb7a86dc25c5f770e3c06a9d57e0e5eba48829", size = 11038678, upload-time = "2024-10-04T13:39:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/13/34/a40ff8ae62fb1b26fb8e6fa7e64bc0e0a834b47317880de22edd6bfb54fb/ruff-0.6.9-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:417b81aa1c9b60b2f8edc463c58363075412866ae4e2b9ab0f690dc1e87ac1b5", size = 11808682, upload-time = "2024-10-04T13:39:52.141Z" }, + { url = "https://files.pythonhosted.org/packages/2e/6d/25a4386ae4009fc798bd10ba48c942d1b0b3e459b5403028f1214b6dd161/ruff-0.6.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3c866b631f5fbce896a74a6e4383407ba7507b815ccc52bcedabb6810fdb3ef7", size = 11330446, upload-time = "2024-10-04T13:39:55.783Z" }, + { url = "https://files.pythonhosted.org/packages/f7/f6/bdf891a9200d692c94ebcd06ae5a2fa5894e522f2c66c2a12dd5d8cb2654/ruff-0.6.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7b118afbb3202f5911486ad52da86d1d52305b59e7ef2031cea3425142b97d6f", size = 12483048, upload-time = "2024-10-04T13:39:58.845Z" }, + { url = "https://files.pythonhosted.org/packages/a7/86/96f4252f41840e325b3fa6c48297e661abb9f564bd7dcc0572398c8daa42/ruff-0.6.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a67267654edc23c97335586774790cde402fb6bbdb3c2314f1fc087dee320bfa", size = 10936855, upload-time = "2024-10-04T13:40:01.818Z" }, + { url = "https://files.pythonhosted.org/packages/45/87/801a52d26c8dbf73424238e9908b9ceac430d903c8ef35eab1b44fcfa2bd/ruff-0.6.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:3ef0cc774b00fec123f635ce5c547dac263f6ee9fb9cc83437c5904183b55ceb", size = 10713007, upload-time = "2024-10-04T13:40:05.384Z" }, + { url = "https://files.pythonhosted.org/packages/be/27/6f7161d90320a389695e32b6ebdbfbedde28ccbf52451e4b723d7ce744ad/ruff-0.6.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:12edd2af0c60fa61ff31cefb90aef4288ac4d372b4962c2864aeea3a1a2460c0", size = 10274594, upload-time = "2024-10-04T13:40:08.801Z" }, + { url = "https://files.pythonhosted.org/packages/00/52/dc311775e7b5f5b19831563cb1572ecce63e62681bccc609867711fae317/ruff-0.6.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:55bb01caeaf3a60b2b2bba07308a02fca6ab56233302406ed5245180a05c5625", size = 10608024, upload-time = "2024-10-04T13:40:11.923Z" }, + { url = "https://files.pythonhosted.org/packages/98/b6/be0a1ddcbac65a30c985cf7224c4fce786ba2c51e7efeb5178fe410ed3cf/ruff-0.6.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:925d26471fa24b0ce5a6cdfab1bb526fb4159952385f386bdcc643813d472039", size = 10982085, upload-time = "2024-10-04T13:40:15.539Z" }, + { url = "https://files.pythonhosted.org/packages/bb/a4/c84bc13d0b573cf7bb7d17b16d6d29f84267c92d79b2f478d4ce322e8e72/ruff-0.6.9-py3-none-win32.whl", hash = "sha256:eb61ec9bdb2506cffd492e05ac40e5bc6284873aceb605503d8494180d6fc84d", size = 8522088, upload-time = "2024-10-04T13:40:19.168Z" }, + { url = "https://files.pythonhosted.org/packages/74/be/fc352bd8ca40daae8740b54c1c3e905a7efe470d420a268cd62150248c91/ruff-0.6.9-py3-none-win_amd64.whl", hash = "sha256:785d31851c1ae91f45b3d8fe23b8ae4b5170089021fbb42402d811135f0b7117", size = 9359275, upload-time = "2024-10-04T13:40:22.852Z" }, + { url = "https://files.pythonhosted.org/packages/3e/14/fd026bc74ded05e2351681545a5f626e78ef831f8edce064d61acd2e6ec7/ruff-0.6.9-py3-none-win_arm64.whl", hash = "sha256:a9641e31476d601f83cd602608739a0840e348bda93fec9f1ee816f8b6798b93", size = 8679879, upload-time = "2024-10-04T13:40:25.797Z" }, +] + +[[package]] +name = "sqlalchemy" +version = "2.0.27" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/fc/327f0072d1f5231d61c715ad52cb7819ec60f0ac80dc1e507bc338919caa/SQLAlchemy-2.0.27.tar.gz", hash = "sha256:86a6ed69a71fe6b88bf9331594fa390a2adda4a49b5c06f98e47bf0d392534f8", size = 9527460, upload-time = "2024-02-13T15:05:33.862Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/0c/8fa87a9989b5f558630024e3576c0ecfc7035e095e620ef3c9d46f48dbbd/SQLAlchemy-2.0.27-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6c5bad7c60a392850d2f0fee8f355953abaec878c483dd7c3836e0089f046bf6", size = 2079962, upload-time = "2024-02-13T15:20:21.266Z" }, + { url = "https://files.pythonhosted.org/packages/7e/59/1e454b001abe1ed2dede531824449efcbb15e00d75f94477f22ed5217367/SQLAlchemy-2.0.27-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3012ab65ea42de1be81fff5fb28d6db893ef978950afc8130ba707179b4284a", size = 2071264, upload-time = "2024-02-13T15:20:26.517Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cc/10176afca37f7e7680fc14fb5fa3a9b4106e1c1eb4c6427df72ad9faee18/SQLAlchemy-2.0.27-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dbcd77c4d94b23e0753c5ed8deba8c69f331d4fd83f68bfc9db58bc8983f49cd", size = 3185801, upload-time = "2024-02-13T16:48:10.634Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/9b9503922d74325e1ed3bac4b1caef1260d08d149394801142082bc06fc1/SQLAlchemy-2.0.27-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d177b7e82f6dd5e1aebd24d9c3297c70ce09cd1d5d37b43e53f39514379c029c", size = 3185705, upload-time = "2024-02-13T15:22:44.993Z" }, + { url = "https://files.pythonhosted.org/packages/80/92/833ab5401757fd84e57538bfe785f5e94a16e21f50b8c19aacbf33f4f4b2/SQLAlchemy-2.0.27-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:680b9a36029b30cf063698755d277885d4a0eab70a2c7c6e71aab601323cba45", size = 3191282, upload-time = "2024-02-13T16:48:14.405Z" }, + { url = "https://files.pythonhosted.org/packages/a9/a9/4f2c2728ae9d6e64dffa1e98e8fd04e6df8370b876c1ebd8018c1ef078e0/SQLAlchemy-2.0.27-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:1306102f6d9e625cebaca3d4c9c8f10588735ef877f0360b5cdb4fdfd3fd7131", size = 3185754, upload-time = "2024-02-13T15:22:48.555Z" }, + { url = "https://files.pythonhosted.org/packages/2d/89/0434e36d6f2f1d5658081e7c451bf090181d3f7298eebe73fcc514098542/SQLAlchemy-2.0.27-cp311-cp311-win32.whl", hash = "sha256:5b78aa9f4f68212248aaf8943d84c0ff0f74efc65a661c2fc68b82d498311fd5", size = 2045609, upload-time = "2024-02-13T15:25:50.809Z" }, + { url = "https://files.pythonhosted.org/packages/a5/82/aa4392972f83b2b2f341c7b4aad1027a5a524e8870dfc27c1d9c348064c9/SQLAlchemy-2.0.27-cp311-cp311-win_amd64.whl", hash = "sha256:15e19a84b84528f52a68143439d0c7a3a69befcd4f50b8ef9b7b69d2628ae7c4", size = 2071688, upload-time = "2024-02-13T15:25:53.912Z" }, + { url = "https://files.pythonhosted.org/packages/c0/f4/891e34108412875c77eb48771a8f8e72e6655363dd0d9b9c87c82eaa4870/SQLAlchemy-2.0.27-py3-none-any.whl", hash = "sha256:1ab4e0448018d01b142c916cc7119ca573803a4745cfe341b8f95657812700ac", size = 1867012, upload-time = "2024-02-13T15:28:20.192Z" }, +] + +[package.optional-dependencies] +asyncio = [ + { name = "greenlet" }, +] + +[[package]] +name = "starlette" +version = "0.41.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1a/4c/9b5764bd22eec91c4039ef4c55334e9187085da2d8a2df7bd570869aae18/starlette-0.41.3.tar.gz", hash = "sha256:0e4ab3d16522a255be6b28260b938eae2482f98ce5cc934cb08dce8dc3ba5835", size = 2574159, upload-time = "2024-11-18T19:45:04.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/00/2b325970b3060c7cecebab6d295afe763365822b1306a12eeab198f74323/starlette-0.41.3-py3-none-any.whl", hash = "sha256:44cedb2b7c77a9de33a8b74b2b90e9f50d11fcf25d8270ea525ad71a25374ff7", size = 73225, upload-time = "2024-11-18T19:45:02.027Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "uritemplate" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/98/60/f174043244c5306c9988380d2cb10009f91563fc4b31293d27e17201af56/uritemplate-4.2.0.tar.gz", hash = "sha256:480c2ed180878955863323eea31b0ede668795de182617fef9c6ca09e6ec9d0e", size = 33267, upload-time = "2025-06-02T15:12:06.318Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/99/3ae339466c9183ea5b8ae87b34c0b897eda475d2aec2307cae60e5cd4f29/uritemplate-4.2.0-py3-none-any.whl", hash = "sha256:962201ba1c4edcab02e60f9a0d3821e82dfc5d2d6662a21abd533879bdb8a686", size = 11488, upload-time = "2025-06-02T15:12:03.405Z" }, +] + +[[package]] +name = "urllib3" +version = "2.6.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1e/24/a2a2ed9addd907787d7aa0355ba36a6cadf1768b934c652ea78acbd59dcd/urllib3-2.6.2.tar.gz", hash = "sha256:016f9c98bb7e98085cb2b4b17b87d2c702975664e4f060c6532e64d1c1a5e797", size = 432930, upload-time = "2025-12-11T15:56:40.252Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/b9/4095b668ea3678bf6a0af005527f39de12fb026516fb3df17495a733b7f8/urllib3-2.6.2-py3-none-any.whl", hash = "sha256:ec21cddfe7724fc7cb4ba4bea7aa8e2ef36f607a4bab81aa6ce42a13dc3f03dd", size = 131182, upload-time = "2025-12-11T15:56:38.584Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.27.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/09/d8/8aa69c76585035ca81851d99c3b00fd6be050aefd478a5376ff9fc5feb69/uvicorn-0.27.1.tar.gz", hash = "sha256:3d9a267296243532db80c83a959a3400502165ade2c1338dea4e67915fd4745a", size = 41151, upload-time = "2024-02-10T12:09:11.325Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/fd/bac111726b6c651f1fa5563145ecba5ff70d36fb140a55e0d79b60b9d65e/uvicorn-0.27.1-py3-none-any.whl", hash = "sha256:5c89da2f3895767472a35556e539fd59f7edbe9b1e9c0e1c99eebeadc61838e4", size = 60809, upload-time = "2024-02-10T12:09:08.934Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "uvloop" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/d5/69900f7883235562f1f50d8184bb7dd84a2fb61e9ec63f3782546fdbd057/uvloop-0.22.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c60ebcd36f7b240b30788554b6f0782454826a0ed765d8430652621b5de674b9", size = 1352420, upload-time = "2025-10-16T22:16:21.187Z" }, + { url = "https://files.pythonhosted.org/packages/a8/73/c4e271b3bce59724e291465cc936c37758886a4868787da0278b3b56b905/uvloop-0.22.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b7f102bf3cb1995cfeaee9321105e8f5da76fdb104cdad8986f85461a1b7b77", size = 748677, upload-time = "2025-10-16T22:16:22.558Z" }, + { url = "https://files.pythonhosted.org/packages/86/94/9fb7fad2f824d25f8ecac0d70b94d0d48107ad5ece03769a9c543444f78a/uvloop-0.22.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53c85520781d84a4b8b230e24a5af5b0778efdb39142b424990ff1ef7c48ba21", size = 3753819, upload-time = "2025-10-16T22:16:23.903Z" }, + { url = "https://files.pythonhosted.org/packages/74/4f/256aca690709e9b008b7108bc85fba619a2bc37c6d80743d18abad16ee09/uvloop-0.22.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56a2d1fae65fd82197cb8c53c367310b3eabe1bbb9fb5a04d28e3e3520e4f702", size = 3804529, upload-time = "2025-10-16T22:16:25.246Z" }, + { url = "https://files.pythonhosted.org/packages/7f/74/03c05ae4737e871923d21a76fe28b6aad57f5c03b6e6bfcfa5ad616013e4/uvloop-0.22.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:40631b049d5972c6755b06d0bfe8233b1bd9a8a6392d9d1c45c10b6f9e9b2733", size = 3621267, upload-time = "2025-10-16T22:16:26.819Z" }, + { url = "https://files.pythonhosted.org/packages/75/be/f8e590fe61d18b4a92070905497aec4c0e64ae1761498cad09023f3f4b3e/uvloop-0.22.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:535cc37b3a04f6cd2c1ef65fa1d370c9a35b6695df735fcff5427323f2cd5473", size = 3723105, upload-time = "2025-10-16T22:16:28.252Z" }, +] + +[[package]] +name = "watchfiles" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/f8/2c5f479fb531ce2f0564eda479faecf253d886b1ab3630a39b7bf7362d46/watchfiles-1.1.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:f57b396167a2565a4e8b5e56a5a1c537571733992b226f4f1197d79e94cf0ae5", size = 406529, upload-time = "2025-10-14T15:04:32.899Z" }, + { url = "https://files.pythonhosted.org/packages/fe/cd/f515660b1f32f65df671ddf6f85bfaca621aee177712874dc30a97397977/watchfiles-1.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:421e29339983e1bebc281fab40d812742268ad057db4aee8c4d2bce0af43b741", size = 394384, upload-time = "2025-10-14T15:04:33.761Z" }, + { url = "https://files.pythonhosted.org/packages/7b/c3/28b7dc99733eab43fca2d10f55c86e03bd6ab11ca31b802abac26b23d161/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e43d39a741e972bab5d8100b5cdacf69db64e34eb19b6e9af162bccf63c5cc6", size = 448789, upload-time = "2025-10-14T15:04:34.679Z" }, + { url = "https://files.pythonhosted.org/packages/4a/24/33e71113b320030011c8e4316ccca04194bf0cbbaeee207f00cbc7d6b9f5/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f537afb3276d12814082a2e9b242bdcf416c2e8fd9f799a737990a1dbe906e5b", size = 460521, upload-time = "2025-10-14T15:04:35.963Z" }, + { url = "https://files.pythonhosted.org/packages/f4/c3/3c9a55f255aa57b91579ae9e98c88704955fa9dac3e5614fb378291155df/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b2cd9e04277e756a2e2d2543d65d1e2166d6fd4c9b183f8808634fda23f17b14", size = 488722, upload-time = "2025-10-14T15:04:37.091Z" }, + { url = "https://files.pythonhosted.org/packages/49/36/506447b73eb46c120169dc1717fe2eff07c234bb3232a7200b5f5bd816e9/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f3f58818dc0b07f7d9aa7fe9eb1037aecb9700e63e1f6acfed13e9fef648f5d", size = 596088, upload-time = "2025-10-14T15:04:38.39Z" }, + { url = "https://files.pythonhosted.org/packages/82/ab/5f39e752a9838ec4d52e9b87c1e80f1ee3ccdbe92e183c15b6577ab9de16/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9bb9f66367023ae783551042d31b1d7fd422e8289eedd91f26754a66f44d5cff", size = 472923, upload-time = "2025-10-14T15:04:39.666Z" }, + { url = "https://files.pythonhosted.org/packages/af/b9/a419292f05e302dea372fa7e6fda5178a92998411f8581b9830d28fb9edb/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aebfd0861a83e6c3d1110b78ad54704486555246e542be3e2bb94195eabb2606", size = 456080, upload-time = "2025-10-14T15:04:40.643Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c3/d5932fd62bde1a30c36e10c409dc5d54506726f08cb3e1d8d0ba5e2bc8db/watchfiles-1.1.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:5fac835b4ab3c6487b5dbad78c4b3724e26bcc468e886f8ba8cc4306f68f6701", size = 629432, upload-time = "2025-10-14T15:04:41.789Z" }, + { url = "https://files.pythonhosted.org/packages/f7/77/16bddd9779fafb795f1a94319dc965209c5641db5bf1edbbccace6d1b3c0/watchfiles-1.1.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:399600947b170270e80134ac854e21b3ccdefa11a9529a3decc1327088180f10", size = 623046, upload-time = "2025-10-14T15:04:42.718Z" }, + { url = "https://files.pythonhosted.org/packages/46/ef/f2ecb9a0f342b4bfad13a2787155c6ee7ce792140eac63a34676a2feeef2/watchfiles-1.1.1-cp311-cp311-win32.whl", hash = "sha256:de6da501c883f58ad50db3a32ad397b09ad29865b5f26f64c24d3e3281685849", size = 271473, upload-time = "2025-10-14T15:04:43.624Z" }, + { url = "https://files.pythonhosted.org/packages/94/bc/f42d71125f19731ea435c3948cad148d31a64fccde3867e5ba4edee901f9/watchfiles-1.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:35c53bd62a0b885bf653ebf6b700d1bf05debb78ad9292cf2a942b23513dc4c4", size = 287598, upload-time = "2025-10-14T15:04:44.516Z" }, + { url = "https://files.pythonhosted.org/packages/57/c9/a30f897351f95bbbfb6abcadafbaca711ce1162f4db95fc908c98a9165f3/watchfiles-1.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:57ca5281a8b5e27593cb7d82c2ac927ad88a96ed406aa446f6344e4328208e9e", size = 277210, upload-time = "2025-10-14T15:04:45.883Z" }, + { url = "https://files.pythonhosted.org/packages/d3/8e/e500f8b0b77be4ff753ac94dc06b33d8f0d839377fee1b78e8c8d8f031bf/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:db476ab59b6765134de1d4fe96a1a9c96ddf091683599be0f26147ea1b2e4b88", size = 408250, upload-time = "2025-10-14T15:06:10.264Z" }, + { url = "https://files.pythonhosted.org/packages/bd/95/615e72cd27b85b61eec764a5ca51bd94d40b5adea5ff47567d9ebc4d275a/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:89eef07eee5e9d1fda06e38822ad167a044153457e6fd997f8a858ab7564a336", size = 396117, upload-time = "2025-10-14T15:06:11.28Z" }, + { url = "https://files.pythonhosted.org/packages/c9/81/e7fe958ce8a7fb5c73cc9fb07f5aeaf755e6aa72498c57d760af760c91f8/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce19e06cbda693e9e7686358af9cd6f5d61312ab8b00488bc36f5aabbaf77e24", size = 450493, upload-time = "2025-10-14T15:06:12.321Z" }, + { url = "https://files.pythonhosted.org/packages/6e/d4/ed38dd3b1767193de971e694aa544356e63353c33a85d948166b5ff58b9e/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e6f39af2eab0118338902798b5aa6664f46ff66bc0280de76fca67a7f262a49", size = 457546, upload-time = "2025-10-14T15:06:13.372Z" }, +] + +[[package]] +name = "websockets" +version = "15.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/32/18fcd5919c293a398db67443acd33fde142f283853076049824fc58e6f75/websockets-15.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:823c248b690b2fd9303ba00c4f66cd5e2d8c3ba4aa968b2779be9532a4dad431", size = 175423, upload-time = "2025-03-05T20:01:56.276Z" }, + { url = "https://files.pythonhosted.org/packages/76/70/ba1ad96b07869275ef42e2ce21f07a5b0148936688c2baf7e4a1f60d5058/websockets-15.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678999709e68425ae2593acf2e3ebcbcf2e69885a5ee78f9eb80e6e371f1bf57", size = 173082, upload-time = "2025-03-05T20:01:57.563Z" }, + { url = "https://files.pythonhosted.org/packages/86/f2/10b55821dd40eb696ce4704a87d57774696f9451108cff0d2824c97e0f97/websockets-15.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d50fd1ee42388dcfb2b3676132c78116490976f1300da28eb629272d5d93e905", size = 173330, upload-time = "2025-03-05T20:01:59.063Z" }, + { url = "https://files.pythonhosted.org/packages/a5/90/1c37ae8b8a113d3daf1065222b6af61cc44102da95388ac0018fcb7d93d9/websockets-15.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d99e5546bf73dbad5bf3547174cd6cb8ba7273062a23808ffea025ecb1cf8562", size = 182878, upload-time = "2025-03-05T20:02:00.305Z" }, + { url = "https://files.pythonhosted.org/packages/8e/8d/96e8e288b2a41dffafb78e8904ea7367ee4f891dafc2ab8d87e2124cb3d3/websockets-15.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66dd88c918e3287efc22409d426c8f729688d89a0c587c88971a0faa2c2f3792", size = 181883, upload-time = "2025-03-05T20:02:03.148Z" }, + { url = "https://files.pythonhosted.org/packages/93/1f/5d6dbf551766308f6f50f8baf8e9860be6182911e8106da7a7f73785f4c4/websockets-15.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dd8327c795b3e3f219760fa603dcae1dcc148172290a8ab15158cf85a953413", size = 182252, upload-time = "2025-03-05T20:02:05.29Z" }, + { url = "https://files.pythonhosted.org/packages/d4/78/2d4fed9123e6620cbf1706c0de8a1632e1a28e7774d94346d7de1bba2ca3/websockets-15.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fdc51055e6ff4adeb88d58a11042ec9a5eae317a0a53d12c062c8a8865909e8", size = 182521, upload-time = "2025-03-05T20:02:07.458Z" }, + { url = "https://files.pythonhosted.org/packages/e7/3b/66d4c1b444dd1a9823c4a81f50231b921bab54eee2f69e70319b4e21f1ca/websockets-15.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:693f0192126df6c2327cce3baa7c06f2a117575e32ab2308f7f8216c29d9e2e3", size = 181958, upload-time = "2025-03-05T20:02:09.842Z" }, + { url = "https://files.pythonhosted.org/packages/08/ff/e9eed2ee5fed6f76fdd6032ca5cd38c57ca9661430bb3d5fb2872dc8703c/websockets-15.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:54479983bd5fb469c38f2f5c7e3a24f9a4e70594cd68cd1fa6b9340dadaff7cf", size = 181918, upload-time = "2025-03-05T20:02:11.968Z" }, + { url = "https://files.pythonhosted.org/packages/d8/75/994634a49b7e12532be6a42103597b71098fd25900f7437d6055ed39930a/websockets-15.0.1-cp311-cp311-win32.whl", hash = "sha256:16b6c1b3e57799b9d38427dda63edcbe4926352c47cf88588c0be4ace18dac85", size = 176388, upload-time = "2025-03-05T20:02:13.32Z" }, + { url = "https://files.pythonhosted.org/packages/98/93/e36c73f78400a65f5e236cd376713c34182e6663f6889cd45a4a04d8f203/websockets-15.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:27ccee0071a0e75d22cb35849b1db43f2ecd3e161041ac1ee9d2352ddf72f065", size = 176828, upload-time = "2025-03-05T20:02:14.585Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, +] + +[[package]] +name = "xmltodict" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/39/0d/40df5be1e684bbaecdb9d1e0e40d5d482465de6b00cbb92b84ee5d243c7f/xmltodict-0.13.0.tar.gz", hash = "sha256:341595a488e3e01a85a9d8911d8912fd922ede5fecc4dce437eb4b6c8d037e56", size = 33813, upload-time = "2022-05-08T07:00:04.916Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/db/fd0326e331726f07ff7f40675cd86aa804bfd2e5016c727fa761c934990e/xmltodict-0.13.0-py2.py3-none-any.whl", hash = "sha256:aa89e8fd76320154a40d19a0df04a4695fb9dc5ba977cbb68ab3e4eb225e7852", size = 9971, upload-time = "2022-05-08T07:00:02.898Z" }, +]