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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 2 additions & 0 deletions .github/workflows/alembic.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/pytest_unit.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 6 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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.<marker>`
# You can then run tests with `pytest -m <marker>` 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"
Expand Down
1 change: 1 addition & 0 deletions src/alembic/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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 ###
37 changes: 37 additions & 0 deletions src/alembic/versions/f54416fbc87f_add_image_asset_table.py
Original file line number Diff line number Diff line change
@@ -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 ###
3 changes: 3 additions & 0 deletions src/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
9 changes: 8 additions & 1 deletion src/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down
3 changes: 3 additions & 0 deletions src/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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]
18 changes: 18 additions & 0 deletions src/image_asset/crud.py
Original file line number Diff line number Diff line change
@@ -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)
29 changes: 29 additions & 0 deletions src/image_asset/models.py
Original file line number Diff line number Diff line change
@@ -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}"
16 changes: 16 additions & 0 deletions src/image_asset/tables.py
Original file line number Diff line number Diff line change
@@ -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())
Loading
Loading