Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
e16a798
feat(env): added python-env package for devs
jbriones1 May 15, 2026
9317dee
fix: add .env to .gitignore
jbriones1 May 15, 2026
9025d0a
feat: add support for TransLink API key
jbriones1 May 15, 2026
499d22e
feat: add realtime data and parse for next bus
jbriones1 Jun 7, 2026
d170599
feat: add static data for bus timetable
jbriones1 May 16, 2026
6df767c
fix: move lifespan declaration to main
jbriones1 May 16, 2026
e967f67
feat: static schedule updates
jbriones1 May 16, 2026
a3942e2
fix: change bus_number to route_number and add docs
jbriones1 May 17, 2026
5226391
fix: remove __init__.py from officers directory
jbriones1 May 17, 2026
2c1a89f
chore: move fetching realtime schedule to crud file
jbriones1 May 17, 2026
6f0f74e
feat: add schedule endpoint
jbriones1 May 17, 2026
f8d1955
fix: add cancelled status
jbriones1 May 17, 2026
52ab295
feat: static schedule now cached when fetched
jbriones1 May 18, 2026
a505f57
fix: add error handling
jbriones1 May 18, 2026
cb93e2e
tests: initial tests added
jbriones1 May 18, 2026
3929a31
docs: add documentation to use the TransLink API
jbriones1 May 18, 2026
3baf643
fix: wrong response model on schedule endpoint
jbriones1 May 18, 2026
d814ff4
docs: fix a typo and add environment variable option
jbriones1 May 18, 2026
02962c0
tests: add client fixture for endpoint tests
jbriones1 May 18, 2026
d853256
test: test database is used exclusively for Pytest
jbriones1 May 20, 2026
dabd5b6
fix: typo from another PR
jbriones1 Jun 4, 2026
cfbe050
fix: added a merge migration between events and translink
jbriones1 Jun 7, 2026
f55e4e2
fix: sort static schedule data by departure_seconds in each route
jbriones1 Jun 25, 2026
344cf49
feat(translink): add 90s realtime cache
jbriones1 Jun 30, 2026
2b8af79
fix: add error handling for empty GTFS responses
jbriones1 Jun 30, 2026
f5cf94b
fix: use the test database when running tests
jbriones1 Jun 30, 2026
bd39213
fix: use minimal FastAPI app so unit tests run
jbriones1 Jun 30, 2026
eedc793
feat: pytest GitHub actions now use uv
jbriones1 Jun 30, 2026
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
16 changes: 4 additions & 12 deletions .github/workflows/pytest_unit.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,20 +13,12 @@ jobs:
with:
python-version: '3.13'

- uses: actions/cache@v5
id: cache
- uses: astral-sh/setup-uv@v6
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.*') }}
restore-keys: |
${{ runner.os }}-pip-
enable-cache: true

- name: Install dependencies
run: |
python -m pip install --upgrade pip
python -m venv venv
source ./venv/bin/activate
pip install ".[test]"
run: uv sync --extra test --locked

- name: Run unit tests
run: PYTHONPATH=src ./venv/bin/python -m pytest ./tests/unit -v
run: uv run pytest ./tests/unit -v
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,4 @@ wheels

.venv
.DS_Store
.env
7 changes: 4 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,22 +12,23 @@ dependencies = [
"asyncpg==0.31.0",
"alembic==1.18.4",
"google-api-python-client==2.194.0",

# minor
"xmltodict==0.13.0",
"httpx==0.28.1",
"pydantic-settings==2.14.1",
"gtfs-realtime-bindings==2.0.0",
"pandas==3.0.3",
]

[project.optional-dependencies]
dev = [
"ruff==0.15.12", # linting and formatter
"pre-commit"
"pre-commit",
]

test = [
"pytest", # test framework
"pytest-asyncio",
"httpx",
]

[project.urls]
Expand Down
1 change: 1 addition & 0 deletions src/alembic/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import officers.tables
import candidates.tables
import event.tables
import translink.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,26 @@
"""Merge events and translink branches

Revision ID: 401b254a02a5
Revises: 42f855bec532, c1a70c8cfd64
Create Date: 2026-06-07 12:25:14.294686

"""
from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision: str = '401b254a02a5'
down_revision: Union[str, None] = ('42f855bec532', 'c1a70c8cfd64')
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
pass


def downgrade() -> None:
pass
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
"""Create TransLink static schedule

Revision ID: c1a70c8cfd64
Revises: 0a2c458d1ddd
Create Date: 2026-05-17 16:17:26.176355

"""
from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql

# revision identifiers, used by Alembic.
revision: str = 'c1a70c8cfd64'
down_revision: Union[str, None] = '0a2c458d1ddd'
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('translink_static_schedule',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('date_fetched', sa.Date(), nullable=False),
sa.Column('schedule', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.PrimaryKeyConstraint('id', name=op.f('pk_translink_static_schedule'))
)
# ### end Alembic commands ###


def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('translink_static_schedule')
# ### end Alembic commands ###
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
"""Create TransLink realtime cache

Revision ID: f0c99d0db277
Revises: 401b254a02a5
Create Date: 2026-06-26 16:54:33.26523

"""

from collections.abc import Sequence

import sqlalchemy as sa
from alembic import op

# revision identifiers, used by Alembic.
revision: str = "f0c99d0db277"
down_revision: str | None = "401b254a02a5"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None


def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"translink_realtime_cache",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("fetched_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("response_bytes", sa.LargeBinary(), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("pk_translink_realtime_cache")),
)
# ### end Alembic commands ###


def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table("translink_realtime_cache")
# ### end Alembic commands ###
1 change: 0 additions & 1 deletion src/auth/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
import os
import urllib.parse

import httpx
import xmltodict
from fastapi import APIRouter, BackgroundTasks, HTTPException, Request, Response
from fastapi.responses import JSONResponse, RedirectResponse
Expand Down
10 changes: 10 additions & 0 deletions src/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env")

translink_api_key: str | None = None


settings = Settings()
19 changes: 3 additions & 16 deletions src/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,7 @@
from typing import Annotated, Any

import asyncpg
import httpx
from fastapi import Depends, FastAPI
from fastapi import Depends
from sqlalchemy import MetaData
from sqlalchemy.ext.asyncio import AsyncConnection, AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase
Expand Down Expand Up @@ -100,26 +99,14 @@ async def session(self) -> AsyncGenerator[AsyncSession]:
def setup_database():
global sessionmanager

db_url = SQLALCHEMY_TEST_DATABASE_URL if os.environ.get("ENV") == "test" else SQLALCHEMY_DATABASE_URL
# 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,
db_url,
{"echo": True},
)


@contextlib.asynccontextmanager
async def lifespan(app: FastAPI):
"""
Handles startup and shutdown events, see https://fastapi.tiangolo.com/advanced/events/
"""
app.state.http_client = httpx.AsyncClient()
yield
await app.state.http_client.aclose()
if sessionmanager._engine is not None:
# Close the DB connection
await sessionmanager.close()


async def get_db_session():
async with sessionmanager.session() as session:
yield session
Expand Down
1 change: 1 addition & 0 deletions src/load_test_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
update_officer_term,
)
from officers.tables import OfficerInfoDB, OfficerTermDB
from translink.tables import TransLinkStaticScheduleDB


async def reset_db(engine):
Expand Down
23 changes: 21 additions & 2 deletions src/main.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
# pyright: reportUnusedImport=false
import contextlib
import logging

import httpx
from fastapi import FastAPI, Request, status
from fastapi.encoders import jsonable_encoder
from fastapi.exceptions import RequestValidationError
Expand All @@ -14,11 +17,26 @@
import nominees.urls
import officers.urls
import permission.urls
import translink.urls
from constants import IS_PROD

logging.basicConfig(level=logging.DEBUG)
database.setup_database()


@contextlib.asynccontextmanager
async def lifespan(app: FastAPI):
"""
Handles startup and shutdown events, see https://fastapi.tiangolo.com/advanced/events/
"""
app.state.http_client = httpx.AsyncClient()
yield
await app.state.http_client.aclose()
if database.sessionmanager._engine is not None:
# Close the DB connection
await database.sessionmanager.close()


# Enable OpenAPI docs only for local development
if not IS_PROD:
print("Running local environment")
Expand All @@ -27,7 +45,7 @@
"http://localhost:8080", # for existing applications/sites
]
app = FastAPI(
lifespan=database.lifespan,
lifespan=lifespan,
title="CSSS Site Backend",
root_path="/api",
)
Expand All @@ -41,7 +59,7 @@
"https://madness.sfucsss.org",
]
app = FastAPI(
lifespan=database.lifespan,
lifespan=lifespan,
title="CSSS Site Backend",
root_path="/api",
docs_url=None, # disables Swagger UI
Expand All @@ -60,6 +78,7 @@
app.include_router(officers.urls.router)
app.include_router(permission.urls.router)
app.include_router(event.urls.router)
app.include_router(translink.urls.router)


@app.get("/")
Expand Down
2 changes: 1 addition & 1 deletion src/nominees/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ async def get_nominee_info(db_session: database.DBSession, computing_id: str):
operation_id="delete_nominee",
dependencies=[Depends(perm_election)],
)
async def delete_nominee_info(db_session: database.tDBSession, computing_id: str):
async def delete_nominee_info(db_session: database.DBSession, computing_id: str):
try:
await nominees.crud.delete_nominee_info(db_session, computing_id)
await db_session.commit()
Expand Down
Empty file removed src/officers/__init__.py
Empty file.
16 changes: 16 additions & 0 deletions src/translink/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# TransLink API Documentation
This is supporting documentation for the TransLink API our server uses.
This server only filters for the buses that begin/end at the upper bus loop at SFU Burnaby campus which are: 143, 144, 145, and R5.
All dates are adjusted for the America/Vancouver timezone.

## Quickstart
1. You need an API key to do anything with realtime data. You can sign up for one [here](https://www.translink.ca/about-us/doing-business-with-translink/app-developer-resources/register
2. Put the API key in `src/.env` with the key `TRANSLINK_API_KEY=<your API key>` or create an environment variable `export TRANSLINK_API_KEY=<your api key>`.
3. Make sure your database has the correct migrations `alembic upgrade head`. Reload your test database as well `python src/load_test_db.py`
4. Start (or restart) the web server to test the endpoints

## Endpoints
You can see the exact schemas in the `/docs` page. At the time this was written there are three endpoints:
1. `translink/realtime`: returns realtime data for buses that are at or are approaching SFU
2. `translink/static`: returns the schedule for the current day
3. `translink/schedule`: combines the realtime and static data to show if a bus is at the loop, is running late, or was cancelled
Loading
Loading