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
1 change: 1 addition & 0 deletions annotation_api/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \

# Copy project files
COPY src/alembic.ini /app/alembic.ini
COPY src/migrations /app/migrations
COPY src/app /app/app

# Install project
Expand Down
13 changes: 13 additions & 0 deletions annotation_api/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ help:
@echo " clean - Remove containers and volumes (fresh start)"
@echo " test - Run comprehensive test suite with coverage"
@echo " test-specific - Run specific test (TEST=test_file.py::test_method)"
@echo " migrate - Autogenerate alembic revision (m=\"description\")"
@echo " migrate-up - Apply pending alembic revisions"
@echo ""
@echo "Sequence annotation workflow (A):"
@echo " pull-sequences - Duplicate N sequences from remote API to local API"
Expand Down Expand Up @@ -105,6 +107,16 @@ test-specific:
docker compose -f docker-compose-dev.yml down --volumes; \
exit $$TEST_EXIT_CODE

# Generate a new alembic migration from the current models.
# Requires the dev stack to be running (make start). Usage: make migrate m="add foo column"
migrate:
@if [ -z "$(m)" ]; then echo "Usage: make migrate m=\"short description\""; exit 1; fi
docker compose -f docker-compose.yml exec -T backend alembic revision --autogenerate -m "$(m)"

# Apply pending alembic migrations to the running DB.
migrate-up:
docker compose -f docker-compose.yml exec -T backend alembic upgrade head

# =========================================================================
# Data workflow targets
# Targets that hit the remote API expect MAIN_ANNOTATION_LOGIN /
Expand Down Expand Up @@ -310,6 +322,7 @@ import-platform:
--loglevel $(LOGLEVEL)

.PHONY: help lint fix docker-build start stop clean test test-specific \
migrate migrate-up \
pull-sequences push-annotations \
pull-seq-annotations auto-annotate visual-check apply-review \
pull-fp visual-check-fp apply-review-fp \
Expand Down
3 changes: 2 additions & 1 deletion annotation_api/docker-compose-dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,9 @@ services:
volumes:
- ./src/app:/app/app
- ./src/alembic.ini:/app/alembic.ini
- ./src/migrations:/app/migrations
- ./src/tests:/app/tests
command: "sh -c 'python app/db.py && uvicorn app.main:app --reload --host 0.0.0.0 --port 5050 --proxy-headers'"
command: "sh -c 'alembic upgrade head && uvicorn app.main:app --reload --host 0.0.0.0 --port 5050 --proxy-headers'"
restart: always
healthcheck:
test: ["CMD-SHELL", "curl http://localhost:5050/status"]
Expand Down
2 changes: 1 addition & 1 deletion annotation_api/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ services:
- JWT_SECRET=your_jwt_secret_key_change_in_production_please
- ACCESS_TOKEN_EXPIRE_HOURS=24
restart: always
command: "sh -c 'python app/db.py && uvicorn app.main:app --host 0.0.0.0 --port 5050 --proxy-headers'"
command: "sh -c 'alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 5050 --proxy-headers'"
healthcheck:
test: ["CMD-SHELL", "curl http://localhost:5050/status"]
interval: 10s
Expand Down
20 changes: 3 additions & 17 deletions annotation_api/src/app/db.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,19 @@
# Copyright (C) 2024, Pyronear.
# Copyright (C) 2024-2026, Pyronear.

# This program is licensed under the Apache License 2.0.
# See LICENSE or go to <https://www.apache.org/licenses/LICENSE-2.0> for full license details.

import asyncio
import logging

from sqlalchemy.ext.asyncio.engine import AsyncEngine
from sqlalchemy.orm import sessionmaker
from sqlmodel import SQLModel, create_engine
from sqlmodel import create_engine
from sqlmodel.ext.asyncio.session import AsyncSession

from app.core.config import settings
from app.models import * # noqa

__all__ = ["get_session", "init_db"]
__all__ = ["get_session"]

logger = logging.getLogger("uvicorn.error")

Expand Down Expand Up @@ -52,16 +51,3 @@ async def get_session() -> AsyncSession: # type: ignore[misc]
)
async with async_session() as session:
yield session


async def init_db() -> None:
async with engine.begin() as conn:
await conn.run_sync(SQLModel.metadata.create_all)


async def main() -> None:
await init_db()


if __name__ == "__main__":
asyncio.run(main())
10 changes: 5 additions & 5 deletions annotation_api/src/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
from app.api.api_v1.router import api_router
from app.core.config import settings
from app.crud import UserCRUD
from app.db import get_session, init_db
from app.db import get_session
from app.schemas.base import Status
from app.schemas.user import UserCreate

Expand All @@ -31,11 +31,11 @@

@asynccontextmanager
async def lifespan(app: FastAPI):
"""Application lifespan manager for startup and shutdown events."""
# Startup
logger.info("Initializing database...")
await init_db()
"""Application lifespan manager for startup and shutdown events.

Database schema is managed by Alembic (see ``src/migrations``); migrations
are applied by the container entrypoint before uvicorn starts.
"""
# Create admin user from environment variables if not exists
async for session in get_session():
user_crud = UserCRUD(session)
Expand Down
47 changes: 47 additions & 0 deletions annotation_api/src/migrations/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# Alembic migrations

Alembic is the sole owner of the database schema. Every table, column,
index and constraint is created and evolved through revision files in
`src/migrations/versions/`.

All commands assume the dev stack is running:

```shell
make start
```

## Create a new revision

After modifying a SQLModel in `src/app/models.py`, autogenerate a
revision file from the diff between your models and the live DB:

```shell
make migrate m="add active column to detections"
```

This is a thin wrapper around:

```shell
docker compose exec -T backend alembic revision --autogenerate -m "<message>"
```

The new file lands in `src/migrations/versions/`. **Always review it
before committing** — `--autogenerate` does not detect renames (it
emits drop+create), enum value additions, `server_default` changes, or
some `CHECK` constraints. Adjust by hand when needed.

## Apply pending revisions

Migrations are applied automatically when the backend container starts
(`alembic upgrade head` is the first thing in the compose command). To
apply without restarting:

```shell
make migrate-up
```

Equivalent to:

```shell
docker compose exec backend alembic upgrade head
```
79 changes: 79 additions & 0 deletions annotation_api/src/migrations/env.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# Copyright (C) 2024-2026, Pyronear.

# This program is licensed under the Apache License 2.0.
# See LICENSE or go to <https://www.apache.org/licenses/LICENSE-2.0> for full license details.

import asyncio
from logging.config import fileConfig

from alembic import context
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import create_async_engine
from sqlmodel import SQLModel
from sqlmodel.sql.sqltypes import AutoString

from app.core.config import settings
from app.models import * # noqa: F401,F403

# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config

# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)

# add your model's MetaData object here for 'autogenerate' support.
target_metadata = SQLModel.metadata


def render_item(type_, obj, autogen_context): # noqa: ARG001
"""Render sqlmodel's AutoString as plain sa.String so migrations don't import sqlmodel."""
if type_ == "type" and isinstance(obj, AutoString):
length = getattr(obj.impl, "length", None)
return f"sa.String(length={length})" if length else "sa.String()"
return False


def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode."""
context.configure(
url=settings.POSTGRES_URL,
target_metadata=target_metadata,
literal_binds=True,
compare_type=True,
dialect_opts={"paramstyle": "named"},
render_item=render_item,
)

with context.begin_transaction():
context.run_migrations()


def do_run_migrations(connection: Connection) -> None:
context.configure(
connection=connection,
target_metadata=target_metadata,
compare_type=True,
render_item=render_item,
)

with context.begin_transaction():
context.run_migrations()


async def run_migrations_online() -> None:
"""Run migrations in 'online' mode."""
connectable = create_async_engine(settings.POSTGRES_URL, echo=False, future=True)

async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)

await connectable.dispose()


if context.is_offline_mode():
run_migrations_offline()
else:
asyncio.run(run_migrations_online())
26 changes: 26 additions & 0 deletions annotation_api/src/migrations/script.py.mako
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
"""${message}

Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}

"""
from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa
${imports if imports else ""}

# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}


def upgrade() -> None:
${upgrades if upgrades else "pass"}


def downgrade() -> None:
${downgrades if downgrades else "pass"}
Loading
Loading