diff --git a/.env.example b/.env.example index 02a302c..e5428f6 100644 --- a/.env.example +++ b/.env.example @@ -9,3 +9,8 @@ AUTH_URL=https://cas.sfu.ca/cas/serviceValidate # Redirect after authentication FRONTEND_ORIGIN=http://localhost:8080 + +# The filesystem location for images and other media files. +MEDIA_ROOT=/srv/csss/media +# The URL used to access the media files. +MEDIA_BASE_URL=/media diff --git a/.github/workflows/alembic.yml b/.github/workflows/alembic.yml index 3ab5019..25164c8 100644 --- a/.github/workflows/alembic.yml +++ b/.github/workflows/alembic.yml @@ -9,6 +9,8 @@ jobs: DB_PORT: "5432" COOKIE_SECURE: "false" FRONTEND_ORIGIN: http://localhost:8080 + MEDIA_ROOT: /srv/csss/media + MEDIA_BASE_URL: /media services: postgres: diff --git a/.github/workflows/pytest_unit.yml b/.github/workflows/pytest_unit.yml index 32cc172..9cd514d 100644 --- a/.github/workflows/pytest_unit.yml +++ b/.github/workflows/pytest_unit.yml @@ -9,6 +9,8 @@ jobs: ENVIRONMENT: test COOKIE_SECURE: "false" FRONTEND_ORIGIN: http://localhost:8080 + MEDIA_ROOT: /srv/csss/media + MEDIA_BASE_URL: /media steps: - uses: actions/checkout@v6 diff --git a/README.md b/README.md index 61e1c54..b353a7b 100755 --- a/README.md +++ b/README.md @@ -40,6 +40,36 @@ ENVIRONMENT=dev # Set this to `test` if you want to use the test database instea ``` You can also create a `.env` file and set those in there. See `.env.example` for more information. +## Environment Variables + +The table below indicates what environment variables we support. +Bolded variables are required or else the server won't start. + + +| In `.env` | Python settings key | Type/Options | Default | Description | +|---------------------|---------------------|-----------------------|------------------------------------------|---------------------------------------------------------------------| +| **ENVIRONMENT** | environment | `dev`, `prod`, `test` | `dev` | Determines some configuration settings on boot up. | +| **COOKIE_SECURE** | cookie_secure | boolean | `false` | True if https is required, false otherwise. | +| **FRONTEND_ORIGIN** | frontend_origin | string | `http://localhost:8080` | The client's URL that will be contacting this web server. | +| **MEDIA_ROOT** | media_root | Path | `/srv/csss/media` | The directory media file uploads will be placed into. | +| **MEDIA_BASE_URL** | media_base_url | string | `/media` | The base URL clients use to retrieve media. | +| AUTH_URL | auth_url | string | `https://cas.sfu.ca/cas/serviceValidate` | The authentication service URL. | +| DB_PORT | db_port | 0 - 65535 | `5444` | The port the database is reachable at, set this if working locally. | +| TRANSLINK_API_KEY | translink_api_key | string | | The API key used to retrieve real-time TransLink schedule data. | +| COOKIE_DOMAIN | cookie_domain | string | | Domain value of the cookie. | +| KIOSK_SECRET | kiosk_secret | string | | The key to use to validate Kiosk requests. | + + +The `ENVIRONMENT` dictates the following behaviour: + +| Value | Database Used | Documentation URL (`/docs`) | Authorization Checks | +|--------|---------------|-----------------------------|----------------------| +| `dev` | main | Enabled | Disabled | +| `test` | test | Enabled | Enabled | +| `prod` | main | Disabled | Enabled | + +- The test suite always uses the `test` environment. +- Alembic always runs its migrations on the main database. ## Important Directories diff --git a/pyproject.toml b/pyproject.toml index 2376c61..adb35c1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,8 @@ dependencies = [ "httpx==0.28.1", "pydantic-settings==2.14.1", "gtfs-realtime-bindings==2.0.0", + "python-multipart>=0.0.32", + "pillow>=12.3.0", ] [project.optional-dependencies] @@ -41,13 +43,16 @@ norecursedirs = ["tests/wip"] # Don't test these paths asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "function" asyncio_default_test_loop_scope = "function" -log_cli = true +log_cli = false log_cli_level = "INFO" addopts = [ + "--import-mode=importlib", "--strict-markers", "--strict-config", "--tb=short" ] +# These allow to mark test functions with decorators: `@pytest.mark.` +# You can then run tests with `pytest -m ` to only run tests with that marker. markers = [ "integration: tests that require database or full app setup", "unit: isolated tests that do not require external services" diff --git a/src/alembic/env.py b/src/alembic/env.py index 32aee61..d3280c0 100644 --- a/src/alembic/env.py +++ b/src/alembic/env.py @@ -15,6 +15,7 @@ import nominees.tables import officers.tables import translink.tables +import image_asset.tables from alembic import context # this is the Alembic Config object, which provides diff --git a/src/alembic/versions/0ae98d0ecf4b_fix_max_length_on_computing_ids.py b/src/alembic/versions/0ae98d0ecf4b_fix_max_length_on_computing_ids.py new file mode 100644 index 0000000..f256416 --- /dev/null +++ b/src/alembic/versions/0ae98d0ecf4b_fix_max_length_on_computing_ids.py @@ -0,0 +1,84 @@ +"""fix max length on computing IDs + +Revision ID: 0ae98d0ecf4b +Revises: caa26d56d842 +Create Date: 2026-08-15 23:55:15.667626 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '0ae98d0ecf4b' +down_revision: Union[str, None] = 'caa26d56d842' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('blog_posts', 'computing_id', + existing_type=sa.VARCHAR(length=32), + type_=sa.String(length=8), + existing_nullable=False) + op.alter_column('election_nominee_application', 'computing_id', + existing_type=sa.VARCHAR(length=32), + type_=sa.String(length=8), + existing_nullable=False) + op.alter_column('election_nominee_info', 'computing_id', + existing_type=sa.VARCHAR(length=32), + type_=sa.String(length=8), + existing_nullable=False) + op.alter_column('officer_info', 'computing_id', + existing_type=sa.VARCHAR(length=32), + type_=sa.String(length=8), + existing_nullable=False) + op.alter_column('officer_term', 'computing_id', + existing_type=sa.VARCHAR(length=32), + type_=sa.String(length=8), + existing_nullable=False) + op.alter_column('site_user', 'computing_id', + existing_type=sa.VARCHAR(length=32), + type_=sa.String(length=8), + existing_nullable=False) + op.alter_column('user_session', 'computing_id', + existing_type=sa.VARCHAR(length=32), + type_=sa.String(length=8), + existing_nullable=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('user_session', 'computing_id', + existing_type=sa.String(length=8), + type_=sa.VARCHAR(length=32), + existing_nullable=False) + op.alter_column('site_user', 'computing_id', + existing_type=sa.String(length=8), + type_=sa.VARCHAR(length=32), + existing_nullable=False) + op.alter_column('officer_term', 'computing_id', + existing_type=sa.String(length=8), + type_=sa.VARCHAR(length=32), + existing_nullable=False) + op.alter_column('officer_info', 'computing_id', + existing_type=sa.String(length=8), + type_=sa.VARCHAR(length=32), + existing_nullable=False) + op.alter_column('election_nominee_info', 'computing_id', + existing_type=sa.String(length=8), + type_=sa.VARCHAR(length=32), + existing_nullable=False) + op.alter_column('election_nominee_application', 'computing_id', + existing_type=sa.String(length=8), + type_=sa.VARCHAR(length=32), + existing_nullable=False) + op.alter_column('blog_posts', 'computing_id', + existing_type=sa.String(length=8), + type_=sa.VARCHAR(length=32), + existing_nullable=False) + # ### end Alembic commands ### diff --git a/src/alembic/versions/f54416fbc87f_add_image_asset_table.py b/src/alembic/versions/f54416fbc87f_add_image_asset_table.py new file mode 100644 index 0000000..6feccb8 --- /dev/null +++ b/src/alembic/versions/f54416fbc87f_add_image_asset_table.py @@ -0,0 +1,37 @@ +"""add image_asset table + +Revision ID: f54416fbc87f +Revises: 0ae98d0ecf4b +Create Date: 2026-08-16 13:51:25.315806 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'f54416fbc87f' +down_revision: Union[str, None] = '0ae98d0ecf4b' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('image_asset', + sa.Column('image_id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('storage_key', sa.Text(), nullable=False), + sa.Column('original_filename', sa.Text(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.PrimaryKeyConstraint('image_id', name=op.f('pk_image_asset')), + sa.UniqueConstraint('storage_key', name=op.f('uq_image_asset_storage_key')) + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('image_asset') + # ### end Alembic commands ### diff --git a/src/config.py b/src/config.py index 5b2f374..29ddddb 100644 --- a/src/config.py +++ b/src/config.py @@ -19,5 +19,8 @@ class Settings(BaseSettings): translink_api_key: str | None = None kiosk_secret: str | None = None + media_root: Path + media_base_url: str + settings = Settings() # pyright: ignore[reportCallIssue] diff --git a/src/database.py b/src/database.py index 50ffc41..e391591 100644 --- a/src/database.py +++ b/src/database.py @@ -7,7 +7,7 @@ import asyncpg from fastapi import Depends from sqlalchemy import MetaData -from sqlalchemy.ext.asyncio import AsyncConnection, AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine from sqlalchemy.orm import DeclarativeBase from config import settings @@ -51,6 +51,13 @@ async def test_connection(sqlalchemy_db_url: str): # TODO: setup logging print(f"successful connection test to {sqlalchemy_db_url}") + @property + def engine(self) -> AsyncEngine: + if self._engine is None: + raise RuntimeError("DatabaseSessionManager is not initialized") + + return self._engine + async def close(self): if self._engine is None: raise Exception("DatabaseSessionManager is not initialized") diff --git a/src/dependencies.py b/src/dependencies.py index c5c0c52..1278a36 100644 --- a/src/dependencies.py +++ b/src/dependencies.py @@ -5,6 +5,7 @@ import auth import auth.crud import database +from config import settings from utils.permissions import is_user_election_admin, is_user_website_admin @@ -54,3 +55,5 @@ async def perm_admin(db_session: database.DBSession, computing_id: LoggedInUser) SiteAdmin = Annotated[str, Depends(perm_admin)] + +PERMISSION_DEPENDENCIES = [perm_election, perm_admin] diff --git a/src/image_asset/crud.py b/src/image_asset/crud.py new file mode 100644 index 0000000..a25d312 --- /dev/null +++ b/src/image_asset/crud.py @@ -0,0 +1,18 @@ +from sqlalchemy import select + +import database +from config import settings +from image_asset.tables import ImageAssetDB + + +async def get_all_image_assets(db_session: database.DBSession) -> list[ImageAssetDB]: + query = select(ImageAssetDB).order_by(ImageAssetDB.image_id.desc()) + return list((await db_session.scalars(query)).all()) + + +def create_image_asset(db_session: database.DBSession, image_asset: ImageAssetDB) -> None: + db_session.add(image_asset) + + +async def delete_image_asset(db_session: database.DBSession, image_asset: ImageAssetDB) -> None: + await db_session.delete(image_asset) diff --git a/src/image_asset/models.py b/src/image_asset/models.py new file mode 100644 index 0000000..b1eacc5 --- /dev/null +++ b/src/image_asset/models.py @@ -0,0 +1,29 @@ +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, Field, computed_field + +from config import settings + + +class ImageAsset(BaseModel): + model_config = ConfigDict(from_attributes=True) + image_id: int = Field( + description="The unique identifier for the image asset.", + ) + + original_filename: str = Field( + description="The filename used when uploading the image.", + ) + + storage_key: str = Field( + description="The path to the image on the storage device.", + ) + + created_at: datetime = Field( + description="The date, time, and timezone this image was created.", + ) + + @computed_field(description="Public URL to access the image asset.") + @property + def image_url(self) -> str: + return f"{settings.media_base_url.rstrip('/')}/{self.storage_key}" diff --git a/src/image_asset/tables.py b/src/image_asset/tables.py new file mode 100644 index 0000000..3a0997f --- /dev/null +++ b/src/image_asset/tables.py @@ -0,0 +1,16 @@ +from datetime import datetime + +from sqlalchemy import func +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.types import DateTime, Integer, Text + +from database import Base + + +class ImageAssetDB(Base): + __tablename__ = "image_asset" + + image_id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + storage_key: Mapped[str] = mapped_column(Text, unique=True) + original_filename: Mapped[str] = mapped_column(Text) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) diff --git a/src/image_asset/urls.py b/src/image_asset/urls.py new file mode 100644 index 0000000..6f9d20e --- /dev/null +++ b/src/image_asset/urls.py @@ -0,0 +1,146 @@ +import logging +import shutil +import warnings +from datetime import UTC, datetime +from pathlib import Path +from uuid import uuid4 + +from fastapi import APIRouter, Depends, HTTPException, UploadFile, status +from PIL import Image, UnidentifiedImageError + +import database +import image_asset.crud +from config import settings +from dependencies import perm_admin +from image_asset.models import ImageAsset +from image_asset.tables import ImageAssetDB +from utils.shared_models import DetailModel + +_logger = logging.getLogger(__name__) + +ALLOWED_IMAGE_TYPES = {"JPEG": "jpg", "PNG": "png", "WEBP": "webp"} + +MAX_PIXELS = 8_000_000 + + +async def validate_upload(file: UploadFile) -> str: + """ + Ensures the uploaded image is a valid, allowed type and not a decompression bomb. + + Args: + file: the uploaded file to validate + + Returns: + the file type of the image + + Raises: + HTTPException: when the file is not a valid image, too large, or corrupted + """ + try: + with warnings.catch_warnings(): + warnings.simplefilter("error", Image.DecompressionBombWarning) + with Image.open(file.file) as image: + if image.format not in ALLOWED_IMAGE_TYPES: + raise HTTPException( + status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, + detail="Unsupported image format.", + ) + + if image.width * image.height > MAX_PIXELS: + raise HTTPException( + status_code=status.HTTP_413_CONTENT_TOO_LARGE, + detail="Image dimensions are too large.", + ) + + image_format = ALLOWED_IMAGE_TYPES[image.format] + image.verify() + except ( + UnidentifiedImageError, + OSError, + Image.DecompressionBombWarning, + Image.DecompressionBombError, + ) as error: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid image.", + ) from error + + # Need to reset file pointer after reading + await file.seek(0) + + return image_format + + +router = APIRouter( + prefix="/image", + tags=["image", "media"], +) + + +@router.get( + "", + description="Get metadata of all image assets, in descending image ID order.", + response_model=list[ImageAsset], + responses={403: {"description": "must be a website admin", "model": DetailModel}}, + operation_id="get_all_image_assets", + dependencies=[Depends(perm_admin)], +) +async def get_all_image_assets(db_session: database.DBSession): + return await image_asset.crud.get_all_image_assets(db_session) + + +@router.post( + "", + description="Create a new image asset.", + response_model=ImageAsset, + status_code=status.HTTP_201_CREATED, + responses={ + 400: {"description": "Image is invalid", "model": DetailModel}, + 403: {"description": "Must be a website admin", "model": DetailModel}, + 413: {"description": f"Maximum resolution of {MAX_PIXELS / 1_000_000} megapixels", "model": DetailModel}, + 415: {"description": "Image format not supported.", "model": DetailModel}, + 500: {"description": "Saving image failed", "model": DetailModel}, + }, + operation_id="create_image_asset", + dependencies=[Depends(perm_admin)], +) +async def create_image_asset_from_upload(file: UploadFile, db_session: database.DBSession): + image_format = await validate_upload(file) + + storage_key = f"images/{uuid4()}.{image_format}" + destination = settings.media_root / storage_key + + try: + destination.parent.mkdir(parents=True, exist_ok=True) + + with destination.open("wb") as output: + shutil.copyfileobj(file.file, output) + except OSError as error: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to save image to storage.", + ) from error + + new_img_asset = ImageAssetDB( + storage_key=storage_key, + original_filename=file.filename, + ) + + try: + image_asset.crud.create_image_asset(db_session, new_img_asset) + await db_session.commit() + await db_session.refresh(new_img_asset) + except Exception as e: + await db_session.rollback() + + try: + destination.unlink(missing_ok=True) + except OSError: + # This logs to ensure we know there's now an orphaned file being stored. + _logger.info("Failed to clean up image after failed DB insertion: %s.", destination) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to clean up image after failed write.", + ) from e + + return new_img_asset diff --git a/src/load_test_db.py b/src/load_test_db.py index 1e24e0f..f4e61f7 100644 --- a/src/load_test_db.py +++ b/src/load_test_db.py @@ -18,6 +18,7 @@ from database import SQLALCHEMY_TEST_DATABASE_URL, Base, DatabaseSessionManager from elections.crud import create_election, update_election from elections.tables import ElectionDB +from image_asset.tables import ImageAssetDB from nominees.crud import create_nominee_info from nominees.tables import NomineeInfoDB from officers.constants import OfficerPositionEnum diff --git a/src/main.py b/src/main.py index 3c7e4f7..add326e 100755 --- a/src/main.py +++ b/src/main.py @@ -15,12 +15,14 @@ import elections.urls import event.urls import honorary.urls +import image_asset.urls import kiosk.urls import nominees.urls import officers.urls import permission.urls import translink.urls from config import settings +from dependencies import PERMISSION_DEPENDENCIES logging.basicConfig(level=logging.DEBUG) @@ -60,6 +62,10 @@ async def lifespan(app: FastAPI): title="CSSS Site Backend", root_path="/api", ) + # Disable authorization checks when on `dev` + if settings.environment == "dev": + for dep in PERMISSION_DEPENDENCIES: + app.dependency_overrides[dep] = lambda: None app.add_middleware( CORSMiddleware, @@ -78,6 +84,7 @@ async def lifespan(app: FastAPI): app.include_router(event.urls.router) app.include_router(honorary.urls.router) app.include_router(kiosk.urls.router) +app.include_router(image_asset.urls.router) @app.get("/") diff --git a/src/scripts/import_media_images.py b/src/scripts/import_media_images.py new file mode 100644 index 0000000..03aceba --- /dev/null +++ b/src/scripts/import_media_images.py @@ -0,0 +1,59 @@ +import asyncio +import logging +from datetime import UTC, datetime +from pathlib import Path + +from sqlalchemy import select + +import database +import image_asset.crud +from config import settings + +_logger = logging.getLogger(__name__) + + +async def import_existing_images(): + await database.setup_database() + image_dir = settings.media_root / "images" + print(f"Searching {image_dir}") + if database.sessionmanager is None: + raise RuntimeError("Database has not been initialized") + + async with database.sessionmanager.session() as session: + for path in image_dir.rglob("*"): + if not path.is_file(): + continue + + storage_key = path.relative_to(settings.media_root).as_posix() + + existing = await session.scalar( + select(image_asset.crud.ImageAssetDB).where(image_asset.crud.ImageAssetDB.storage_key == storage_key) + ) + + if existing is not None: + continue + + print(f"Adding {storage_key}") + asset = image_asset.crud.ImageAssetDB( + storage_key=storage_key, + original_filename=path.name, + created_at=datetime.now(UTC), + ) + + session.add(asset) + + await session.commit() + + +def main() -> int: + logging.basicConfig(level=logging.INFO) + try: + asyncio.run(import_existing_images()) + except Exception: + _logger.exception("Failed to import existing images") + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/conftest.py b/tests/conftest.py index a6aa0f1..824cc0f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,7 +1,7 @@ import logging import os -os.environ["ENV"] = "test" +os.environ["ENVIRONMENT"] = "test" def pytest_configure(config): diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index aa648db..f013a7e 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -4,6 +4,7 @@ import pytest_asyncio from httpx import ASGITransport, AsyncClient +from sqlalchemy.ext.asyncio import AsyncConnection, AsyncSession from auth.crud import create_user_session, remove_user_session from database import SQLALCHEMY_TEST_DATABASE_URL, DatabaseSessionManager, get_db_session @@ -24,15 +25,32 @@ async def test_database(): @pytest_asyncio.fixture(scope="function", loop_scope="session") -async def db_session(test_database: DatabaseSessionManager): - async with test_database.session() as session: +async def db_connection(test_database: DatabaseSessionManager): + async with test_database.engine.connect() as connection: + transaction = await connection.begin() + + try: + yield connection + finally: + await transaction.rollback() + + +@pytest_asyncio.fixture(scope="function", loop_scope="session") +async def db_session(db_connection: AsyncConnection): + async with AsyncSession( + bind=db_connection, + join_transaction_mode="create_savepoint", + ) as session: yield session -@pytest_asyncio.fixture(scope="module", loop_scope="session") -async def client(test_database: DatabaseSessionManager) -> AsyncGenerator[Any]: +@pytest_asyncio.fixture(scope="function", loop_scope="session") +async def client(db_connection: AsyncConnection) -> AsyncGenerator[AsyncClient]: async def override_get_db_session(): - async with test_database.session() as session: + async with AsyncSession( + bind=db_connection, + join_transaction_mode="create_savepoint", + ) as session: yield session app.dependency_overrides[get_db_session] = override_get_db_session @@ -45,11 +63,15 @@ async def override_get_db_session(): app.dependency_overrides.clear() -@pytest_asyncio.fixture(scope="module", loop_scope="session") -async def admin_client(test_database: DatabaseSessionManager, client: AsyncClient): +@pytest_asyncio.fixture(scope="function", loop_scope="session") +async def admin_client(db_connection: AsyncConnection, client: AsyncClient): session_id = "temp_id_" + SYSADMIN_COMPUTING_ID client.cookies = {"session_id": session_id} - async with test_database.session() as session: + async with AsyncSession( + bind=db_connection, + join_transaction_mode="create_savepoint", + ) as session: await create_user_session(session, session_id, SYSADMIN_COMPUTING_ID) - yield client - await remove_user_session(session, session_id) + await session.commit() + + yield client diff --git a/tests/integration/test_elections.py b/tests/integration/test_elections.py index ed90a70..8376044 100644 --- a/tests/integration/test_elections.py +++ b/tests/integration/test_elections.py @@ -9,9 +9,12 @@ from candidates.crud import get_all_candidates_in_election from database import DBSession from elections.crud import ( + create_election, get_all_elections, get_election, ) +from elections.models import ElectionTypeEnum +from elections.tables import ElectionDB from nominees.crud import ( get_nominee_info, ) @@ -242,7 +245,7 @@ async def test__admin_get_single_election_with_nominees_true(admin_client: Async assert_private_candidate_fields(candidate) -async def test__admin_create_election(admin_client: AsyncClient): +async def test__admin_create_election(db_session: DBSession, admin_client: AsyncClient): # ensure that authorized users can create an election response = await admin_client.post( "/election", @@ -336,7 +339,21 @@ async def test__admin_create_candidate(admin_client: AsyncClient): assert response.json()["speech"] is None -async def test__admin_update_election(admin_client: AsyncClient): +async def test__admin_update_election(db_session: DBSession, admin_client: AsyncClient): + await create_election( + db_session, + ElectionDB( + slug="testElection4", + name="testElection4", + type=ElectionTypeEnum.GENERAL, + datetime_start_nominations=datetime.datetime.now(datetime.UTC) - timedelta(days=1), + datetime_start_voting=datetime.datetime.now(datetime.UTC) + timedelta(days=7), + datetime_end_voting=datetime.datetime.now(datetime.UTC) + timedelta(days=14), + available_positions=["president", "treasurer"], + survey_link="https://youtu.be/dQw4w9WgXcQ?si=kZROi2tu-43MXPM5", + ), + ) + await db_session.commit() # update the above election response = await admin_client.patch( "/election/testElection4", diff --git a/tests/integration/test_image_asset.py b/tests/integration/test_image_asset.py new file mode 100644 index 0000000..53896c9 --- /dev/null +++ b/tests/integration/test_image_asset.py @@ -0,0 +1,263 @@ +from datetime import UTC, datetime +from http import HTTPStatus +from io import BytesIO +from pathlib import Path +from uuid import UUID + +import pytest +from fastapi import UploadFile, status +from httpx import AsyncClient +from PIL import Image +from pydantic import TypeAdapter +from sqlalchemy.exc import IntegrityError + +import image_asset.crud +import image_asset.urls as image_urls +from config import settings +from database import DBSession +from image_asset.models import ImageAsset +from image_asset.urls import MAX_PIXELS + +pytestmark = pytest.mark.asyncio(loop_scope="session") + + +@pytest.fixture(autouse=True) +def patch_env(monkeypatch: pytest.MonkeyPatch, tmp_path: Path): + monkeypatch.setattr(settings, "media_root", tmp_path) + + +def make_image( + image_format: str = "PNG", + size: tuple[int, int] = (10, 10), +) -> bytes: + """ + Creates a file-like image in-memory. + + Args: + image_format: image format to create e.g., "PNG", "JPEG" + size: a tuple of width and height in pixels for the image + + Returns: + An UploadFile object containing the image data. + """ + buffer = BytesIO() + + image = Image.new("RGB", size) + image.save(buffer, format=image_format) + + return buffer.getvalue() + + +# CRUD +async def test__create_image_asset(db_session: DBSession): + asset = image_asset.crud.ImageAssetDB( + storage_key="images/test.png", original_filename="test.png", created_at=datetime.now(UTC) + ) + + image_asset.crud.create_image_asset(db_session, asset) + + await db_session.commit() + await db_session.refresh(asset) + + assert asset.image_id is not None + + +async def test__get_all_image_assets_is_descending_order(db_session: DBSession): + for i in range(2): + asset = image_asset.crud.ImageAssetDB( + storage_key=f"images/test{i}.png", original_filename=f"test{i}.png", created_at=datetime.now(UTC) + ) + image_asset.crud.create_image_asset(db_session, asset) + + await db_session.commit() + + res = await image_asset.crud.get_all_image_assets(db_session) + + assert len(res) == 2 + # It returns it in descending image_id order + assert res[0].storage_key == "images/test1.png" + assert res[1].storage_key == "images/test0.png" + + +async def test__duplicate_storage_keys_fails(db_session: DBSession): + for _ in range(2): + asset = image_asset.crud.ImageAssetDB( + storage_key="images/test.png", original_filename="test.png", created_at=datetime.now(UTC) + ) + image_asset.crud.create_image_asset(db_session, asset) + + with pytest.raises(IntegrityError): + await db_session.flush() + + +async def test__delete_image_asset(db_session: DBSession): + asset = image_asset.crud.ImageAssetDB( + storage_key="images/test.png", original_filename="test.png", created_at=datetime.now(UTC) + ) + image_asset.crud.create_image_asset(db_session, asset) + + await db_session.commit() + await db_session.refresh(asset) + + await image_asset.crud.delete_image_asset(db_session, asset) + + await db_session.commit() + + res = await image_asset.crud.get_all_image_assets(db_session) + assert len(res) == 0 + + +# Unauthenticated client +async def test__get_all_image_asset_metadata(client: AsyncClient): + response = await client.get("/image") + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + +async def test__upload_image_asset(client: AsyncClient): + response = await client.post("/image") + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + +# TODO: Unauthorized client + + +# Authorized client +async def test__admin_get_all_image_asset_metadata(db_session: DBSession, admin_client: AsyncClient): + # TODO: Replace this data with a mock factory function. + for i in range(2): + asset = image_asset.crud.ImageAssetDB( + storage_key=f"images/test{i}.png", original_filename=f"test{i}.png", created_at=datetime.now(UTC) + ) + image_asset.crud.create_image_asset(db_session, asset) + await db_session.commit() + response = await admin_client.get("/image") + assert response.status_code == status.HTTP_200_OK + data = TypeAdapter(list[ImageAsset]).validate_python(response.json()) + + assert len(data) == 2 + assert data[0].storage_key == "images/test1.png" + assert data[1].storage_key == "images/test0.png" + + +@pytest.mark.parametrize( + ("image_format", "filename", "content_type", "extension"), + [ + ("PNG", "test.png", "image/png", ".png"), + ("JPEG", "test.jpg", "image/jpeg", ".jpg"), + ("WEBP", "test.webp", "image/webp", ".webp"), + ], + ids=["png", "jpeg", "webp"], +) +async def test__admin_upload_good_image( + db_session: DBSession, + admin_client: AsyncClient, + tmp_path: Path, + image_format: str, + filename: str, + content_type: str, + extension: str, +): + image_bytes = make_image(image_format) + + response = await admin_client.post( + "/image", + files={ + "file": ( + filename, + image_bytes, + content_type, + ) + }, + ) + + assert response.status_code == status.HTTP_201_CREATED + + asset = ImageAsset.model_validate(response.json()) + + assert asset.image_id is not None + assert asset.original_filename == filename + assert asset.storage_key.startswith("images/") + assert asset.storage_key.endswith(extension) + + db_asset = await db_session.get(image_asset.crud.ImageAssetDB, asset.image_id) + + assert db_asset is not None + assert db_asset.storage_key == asset.storage_key + assert db_asset.original_filename == filename + + saved_file = tmp_path / asset.storage_key + + assert saved_file.exists() + assert saved_file.is_file() + + +@pytest.mark.parametrize( + ("filename", "content", "content_type", "http_status"), + [ + ("invalid.png", b"invalid image", "image/png", status.HTTP_400_BAD_REQUEST), + ("test.gif", make_image("GIF"), "image/gif", status.HTTP_415_UNSUPPORTED_MEDIA_TYPE), + ("test.png", make_image(size=(int(MAX_PIXELS / 2), 3)), "image/png", status.HTTP_413_CONTENT_TOO_LARGE), + ], + ids=["invalid", "unsupported", "oversized"], +) +async def test__admin_upload_invalid_image( + db_session: DBSession, + admin_client: AsyncClient, + tmp_path: Path, + filename: str, + content: bytes, + content_type: str, + http_status: int, +): + + response = await admin_client.post( + "/image", + files={ + "file": ( + filename, + content, + content_type, + ) + }, + ) + + # Response is proper + assert response.status_code == http_status + + # No database entry created + assets = await image_asset.crud.get_all_image_assets(db_session) + assert assets == [] + + # Physical file exists and has the correct name + assert not any(path.is_file() for path in tmp_path.rglob("*")) + + +async def test__admin_failed_db_insert_is_cleaned_up( + db_session: DBSession, admin_client: AsyncClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + fixed_uuid = UUID("00000000-0000-0000-0000-000000000001") + + monkeypatch.setattr(image_urls, "uuid4", lambda: fixed_uuid) + + storage_key = f"images/{fixed_uuid}.png" + + existing_asset = image_asset.crud.ImageAssetDB( + storage_key=storage_key, + original_filename="existing.png", + created_at=datetime.now(UTC), + ) + + image_asset.crud.create_image_asset(db_session, existing_asset) + await db_session.commit() + + image_bytes = make_image() + + response = await admin_client.post( + "/image", + files={"file": ("test.png", image_bytes, "image/png")}, + ) + + assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR + saved_file = tmp_path / storage_key + + assert not saved_file.exists() diff --git a/tests/integration/test_nominees.py b/tests/integration/test_nominees.py index 6226ad0..edae196 100644 --- a/tests/integration/test_nominees.py +++ b/tests/integration/test_nominees.py @@ -1,8 +1,11 @@ -from http import HTTPStatus - import pytest +from fastapi import status from httpx import AsyncClient +from database import DBSession +from nominees.crud import create_nominee_info +from nominees.tables import NomineeInfoDB + pytestmark = pytest.mark.asyncio(loop_scope="session") TEST_NOMINEE = { @@ -22,33 +25,39 @@ "discord_username": "new_discord#5678", } + +async def insert_test_nominee(db_session: DBSession): + await create_nominee_info(db_session, NomineeInfoDB(**TEST_NOMINEE)) + await db_session.commit() + + # TODO: Modify the test database to be empty # Unauthenticated requests async def test__create_nominees(client: AsyncClient): response = await client.post("/nominee", json=TEST_NOMINEE) - assert response.status_code == HTTPStatus.UNAUTHORIZED + assert response.status_code == status.HTTP_401_UNAUTHORIZED async def test__get_nominees(client: AsyncClient): response = await client.get("/nominee") - assert response.status_code == HTTPStatus.UNAUTHORIZED + assert response.status_code == status.HTTP_401_UNAUTHORIZED async def test__get_one_nominee(client: AsyncClient): response = await client.get(f"/nominee/{TEST_NOMINEE['computing_id']}") - assert response.status_code == HTTPStatus.UNAUTHORIZED + assert response.status_code == status.HTTP_401_UNAUTHORIZED async def test__update_nominee(client: AsyncClient): response = await client.patch(f"/nominee/{TEST_NOMINEE['computing_id']}", json=PATCH_NOMINEE) - assert response.status_code == HTTPStatus.UNAUTHORIZED + assert response.status_code == status.HTTP_401_UNAUTHORIZED async def test__delete_nominee(client: AsyncClient): response = await client.delete(f"/nominee/{TEST_NOMINEE['computing_id']}") - assert response.status_code == HTTPStatus.UNAUTHORIZED + assert response.status_code == status.HTTP_401_UNAUTHORIZED # TODO: Test an election officer trying to change other election information @@ -56,55 +65,55 @@ async def test__delete_nominee(client: AsyncClient): # Election Officer requests # async def test__admin_create_nominees(admin_client: AsyncClient): # response = await admin_client.post("/nominee", json=TEST_NOMINEE) -# assert response.status_code == HTTPStatus.OK +# assert response.status_code == status.HTTP_200_OK # assert TEST_NOMINEE == response.json() # # # async def test__admin_get_nominees(admin_client: AsyncClient): # response = await admin_client.get("/nominee") -# assert response.status_code == HTTPStatus.OK +# assert response.status_code == status.HTTP_200_OK # # FIXME: This should be 2 if the test database is empty # assert len(response.json()) == 3 # # # async def test__admin_get_one_nominee(admin_client: AsyncClient): # response = await admin_client.get(f"/nominee/{TEST_NOMINEE['computing_id']}") -# assert response.status_code == HTTPStatus.OK +# assert response.status_code == status.HTTP_200_OK # # # async def test__admin_update_nominee(admin_client: AsyncClient): # response = await admin_client.patch(f"/nominee/{TEST_NOMINEE['computing_id']}", json={"full_name": "Should Fail"}) -# assert response.status_code == HTTPStatus.OK +# assert response.status_code == status.HTTP_200_OK # # # async def test__admin_delete_nominee(admin_client: AsyncClient): # response = await admin_client.delete(f"/nominee/{TEST_NOMINEE['computing_id']}") -# assert response.status_code == HTTPStatus.OK +# assert response.status_code == status.HTTP_200_OK # Admin requests async def test__admin_create_nominees(admin_client: AsyncClient): response = await admin_client.post("/nominee", json=TEST_NOMINEE) - assert response.status_code == HTTPStatus.OK + assert response.status_code == status.HTTP_200_OK assert TEST_NOMINEE == response.json() async def test__admin_get_nominees(admin_client: AsyncClient): + # TODO: Add inserts response = await admin_client.get("/nominee") - assert response.status_code == HTTPStatus.OK - # FIXME: This should be 1 if the test database is empty - assert len(response.json()) == 3 - test_nominee = next(n for n in response.json() if n["computing_id"] == TEST_NOMINEE["computing_id"]) - assert test_nominee == TEST_NOMINEE + assert response.status_code == status.HTTP_200_OK + assert len(response.json()) == 2 -async def test__admin_get_one_nominee(admin_client: AsyncClient): +async def test__admin_get_one_nominee(db_session: DBSession, admin_client: AsyncClient): + await insert_test_nominee(db_session) response = await admin_client.get(f"/nominee/{TEST_NOMINEE['computing_id']}") - assert response.status_code == HTTPStatus.OK + assert response.status_code == status.HTTP_200_OK assert response.json() == TEST_NOMINEE -async def test__admin_update_nominee(admin_client: AsyncClient): +async def test__admin_update_nominee(db_session: DBSession, admin_client: AsyncClient): + await insert_test_nominee(db_session) response = await admin_client.patch( f"/nominee/{TEST_NOMINEE['computing_id']}", json={ @@ -118,7 +127,7 @@ async def test__admin_update_nominee(admin_client: AsyncClient): f"/nominee/{TEST_NOMINEE['computing_id']}", json=PATCH_NOMINEE, ) - assert response.status_code == HTTPStatus.OK + assert response.status_code == status.HTTP_200_OK expected_response = dict(PATCH_NOMINEE) expected_response["computing_id"] = TEST_NOMINEE["computing_id"] assert response.json() == expected_response @@ -126,5 +135,5 @@ async def test__admin_update_nominee(admin_client: AsyncClient): async def test__admin_delete_nominee(admin_client: AsyncClient): response = await admin_client.delete(f"/nominee/{TEST_NOMINEE['computing_id']}") - assert response.status_code == HTTPStatus.OK + assert response.status_code == status.HTTP_200_OK assert response.json()["success"] diff --git a/tests/integration/test_officers.py b/tests/integration/test_officers.py index d771cc2..3a0f1e1 100644 --- a/tests/integration/test_officers.py +++ b/tests/integration/test_officers.py @@ -319,7 +319,7 @@ async def test__admin_patch_officer_term(admin_client: AsyncClient): assert modifiedTerm["biography"] != "hello o77" response = await admin_client.get("officers/all?include_future_terms=True") - assert len(response.json()) == 10 + assert len(response.json()) == 9 response = await admin_client.delete("officers/term/1") assert response.status_code == 200 @@ -331,4 +331,4 @@ async def test__admin_patch_officer_term(admin_client: AsyncClient): assert response.status_code == 200 response = await admin_client.get("officers/all?include_future_terms=True") - assert len(response.json()) == 6 + assert len(response.json()) == 5 diff --git a/tests/unit/test_image_asset.py b/tests/unit/test_image_asset.py new file mode 100644 index 0000000..27de1a5 --- /dev/null +++ b/tests/unit/test_image_asset.py @@ -0,0 +1,116 @@ +from io import BytesIO + +import pytest +from fastapi import HTTPException, UploadFile, status +from PIL import Image + +from image_asset.urls import ALLOWED_IMAGE_TYPES, MAX_PIXELS, validate_upload + +pytestmark = pytest.mark.unit + + +def make_image( + image_format: str = "PNG", + size: tuple[int, int] = (10, 10), +) -> UploadFile: + """ + Creates a temporary image in-memory. + + Args: + image_format: image format to create e.g., "PNG", "JPEG" + size: a tuple of width and height in pixels for the image + + Returns: + An UploadFile object containing the image data. + """ + buffer = BytesIO() + + image = Image.new("RGB", size) + image.save(buffer, format=image_format) + + buffer.seek(0) + + return UploadFile( + filename=f"test.{image_format.lower()}", + file=buffer, + ) + + +def make_corrupted_image( + image_format: str = "PNG", + size: tuple[int, int] = (10, 10), +) -> UploadFile: + """ + Creates a temporary image in-memory and then truncates some bytes to corrupt it. + + Args: + image_format: image format to create e.g., "PNG", "JPEG" + size: a tuple of width and height in pixels for the image + + Returns: + An UploadFile object containing the image data. + """ + buffer = BytesIO() + + image = Image.new("RGB", size) + image.save(buffer, format=image_format) + + data = buffer.getvalue() + + corrupted_data = data[:-10] # Remove the last 10 bytes to corrupt the image + + return UploadFile( + filename=f"test.{image_format.lower()}", + file=BytesIO(corrupted_data), + ) + + +async def test__supported_image_types_are_valid(): + for pil_type, img_type in ALLOWED_IMAGE_TYPES.items(): + file = make_image(pil_type) + result = await validate_upload(file) + + assert result == img_type + + +async def test__maximum_image_resolution_accepted(): + file = make_image(size=(int(MAX_PIXELS / 2), 2)) + result = await validate_upload(file) + + assert result == "png" + + +async def test__invalid_image(): + file = make_image("PDF") + + with pytest.raises(HTTPException) as ex: + await validate_upload(file) + + assert ex.value.status_code == status.HTTP_400_BAD_REQUEST + + +async def test__unsupported_image(): + file = make_image("GIF") + + with pytest.raises(HTTPException) as ex: + await validate_upload(file) + + assert ex.value.status_code == status.HTTP_415_UNSUPPORTED_MEDIA_TYPE + + +async def test__image_resolution_high(): + file = make_image("PNG", size=(int(MAX_PIXELS / 2) + 1, 2)) + + with pytest.raises(HTTPException) as ex: + await validate_upload(file) + + assert ex.value.status_code == status.HTTP_413_CONTENT_TOO_LARGE + + +async def test__corrupted_image(): + file = make_corrupted_image() + + with pytest.raises(HTTPException) as ex: + await validate_upload(file) + + assert ex.value.status_code == status.HTTP_400_BAD_REQUEST diff --git a/uv.lock b/uv.lock index 1f54a3c..e47d064 100644 --- a/uv.lock +++ b/uv.lock @@ -205,7 +205,9 @@ dependencies = [ { name = "gtfs-realtime-bindings" }, { name = "gunicorn" }, { name = "httpx" }, + { name = "pillow" }, { name = "pydantic-settings" }, + { name = "python-multipart" }, { name = "sqlalchemy", extra = ["asyncio"] }, { name = "uvicorn", extra = ["standard"] }, { name = "xmltodict" }, @@ -230,10 +232,12 @@ requires-dist = [ { name = "gtfs-realtime-bindings", specifier = "==2.0.0" }, { name = "gunicorn", specifier = "==25.3.0" }, { name = "httpx", specifier = "==0.28.1" }, + { name = "pillow", specifier = ">=12.3.0" }, { name = "pre-commit", marker = "extra == 'dev'" }, { name = "pydantic-settings", specifier = "==2.14.1" }, { name = "pytest", marker = "extra == 'test'" }, { name = "pytest-asyncio", marker = "extra == 'test'" }, + { name = "python-multipart", specifier = ">=0.0.32" }, { name = "ruff", marker = "extra == 'dev'", specifier = "==0.15.12" }, { name = "sqlalchemy", extras = ["asyncio"], specifier = "==2.0.49" }, { name = "uvicorn", extras = ["standard"], specifier = "==0.46.0" }, @@ -538,6 +542,26 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, ] +[[package]] +name = "pillow" +version = "12.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" }, + { url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" }, + { url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" }, + { url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" }, + { url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" }, + { url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" }, + { url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" }, + { url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" }, + { url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" }, +] + [[package]] name = "platformdirs" version = "4.9.6" @@ -752,6 +776,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, ] +[[package]] +name = "python-multipart" +version = "0.0.32" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3"