Skip to content
Open
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
8 changes: 7 additions & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,10 @@ jobs:
env:
PYTHONPATH: .
run: |
python -m pytest tests/ -v --tb=short
python -m pytest tests/ -v --tb=short
- name: Check for un-migrated model changes
env:
PYTHONPATH: .
run: |
python -m alembic check
10 changes: 9 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.PHONY: help init fireform build up down logs logs-app logs-ollama shell pull-model test clean super-clean
.PHONY: help init fireform build up down logs logs-app logs-ollama shell pull-model test clean super-clean migrate migration

COMPOSE = docker compose -f docker/dev/compose.yml --env-file docker/.env.dev
ENV_DEV = docker/.env.dev
Expand Down Expand Up @@ -28,6 +28,8 @@ help:
@echo "make shell - Open shell in running app container"
@echo "make pull-model - Pull Ollama model from .env.dev ($(OLLAMA_MODEL))"
@echo "make test - Run test suite"
@echo "make migrate - Run pending Alembic migrations"
@echo "make migration - Generate new migration (msg='description')"
@echo "make clean - Stop containers (preserves volumes)"
@echo "make super-clean - [CAUTION] Stop containers, delete volumes, prune Docker"

Expand Down Expand Up @@ -89,6 +91,12 @@ pull-model:
test:
@$(COMPOSE) exec -T app python3 -m pytest tests/ -v

migrate:
@$(COMPOSE) exec -T app alembic upgrade head

migration:
@$(COMPOSE) exec -T app alembic revision --autogenerate -m "$(msg)"

clean:
@$(COMPOSE) down

Expand Down
37 changes: 37 additions & 0 deletions alembic.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
[alembic]
script_location = alembic
prepend_sys_path = .
path_separator = os

[loggers]
keys = root,sqlalchemy,alembic

[handlers]
keys = console

[formatters]
keys = generic

[logger_root]
level = WARN
handlers = console

[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine

[logger_alembic]
level = INFO
handlers =
qualname = alembic

[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic

[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
50 changes: 50 additions & 0 deletions alembic/env.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
from logging.config import fileConfig

from alembic import context
from sqlalchemy import engine_from_config, pool

from app.core.config import DATABASE_URL
from app.models.models import SQLModel
from app.models import Template, FormSubmission

config = context.config

if not config.get_main_option("sqlalchemy.url"):
config.set_main_option("sqlalchemy.url", DATABASE_URL)

if config.config_file_name is not None:
fileConfig(config.config_file_name)

target_metadata = SQLModel.metadata


def run_migrations_offline():
url = config.get_main_option("sqlalchemy.url")
context.configure(url=url, target_metadata=target_metadata, literal_binds=True)
with context.begin_transaction():
context.run_migrations()


def run_migrations_online():
connectable = config.attributes.get("connection", None)

if connectable is None:
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
else:
context.configure(connection=connectable, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()


if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
27 changes: 27 additions & 0 deletions alembic/script.py.mako
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
"""${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
import sqlmodel
${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"}
52 changes: 52 additions & 0 deletions alembic/versions/001_initial_schema.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
"""Initial schema — Template and FormSubmission tables.

Revision ID: 001
Revises:
Create Date: 2026-06-22

"""
from typing import Sequence, Union

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The modern alternative to Python's typing.Union is the | (pipe) operator, introduced via PEP 604.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same for sequence. If you are using typing.Sequence for type hinting, the modern alternative is to use collections.abc.Sequence or the built-in list generic. Python has moved away from the bulky typing module. Modern Python allows you to use builtin collections directly with subscripts (like list[int]) and union types.


from alembic import op
import sqlalchemy as sa
import sqlmodel

revision: str = "001"
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
op.create_table(
"template",
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
sa.Column("name", sqlmodel.sql.sqltypes.AutoString, nullable=False),
sa.Column("fields", sa.JSON, nullable=False),
sa.Column("pdf_path", sqlmodel.sql.sqltypes.AutoString, nullable=False),
sa.Column(
"created_at",
sa.DateTime,
nullable=False,
server_default=sa.text("now()"),
),
)

op.create_table(
"formsubmission",
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
sa.Column("template_id", sa.Integer, sa.ForeignKey("template.id"), nullable=False),
sa.Column("input_text", sqlmodel.sql.sqltypes.AutoString, nullable=False),
sa.Column("output_pdf_path", sqlmodel.sql.sqltypes.AutoString, nullable=False),
sa.Column(
"created_at",
sa.DateTime,
nullable=False,
server_default=sa.text("now()"),
),
)


def downgrade() -> None:
op.drop_table("formsubmission")
op.drop_table("template")
24 changes: 18 additions & 6 deletions app/db/init_db.py
Original file line number Diff line number Diff line change
@@ -1,24 +1,37 @@
import datetime
import logging
from pathlib import Path

from sqlmodel import Session, SQLModel, select
from alembic import command
from alembic.config import Config
from sqlmodel import Session, select

from app.core.config import DEFAULT_TEMPLATE_DIR
from app.db.database import engine

from app.models import FormSubmission, Template # noqa: F401

logger = logging.getLogger(__name__)

ALEMBIC_INI = str(Path(__file__).resolve().parents[2] / "alembic.ini")


def _alembic_cfg() -> Config:
cfg = Config(ALEMBIC_INI)
return cfg


def run_migrations():
logger.info("Running Alembic migrations...")
command.upgrade(_alembic_cfg(), "head")
logger.info("Migrations complete.")


def seed_db():
with Session(engine) as session:
# Check if we already have templates
statement = select(Template)
try:
results = session.exec(statement).first()
except Exception:
# Table might not exist yet if called at a weird time
results = None

if not results:
Expand All @@ -33,7 +46,6 @@ def seed_db():
"Date": "string",
}

# Using ID 2 as agreed to avoid any ID 1 corruption
default_template = Template(
id=2,
name="Manual Test Template",
Expand All @@ -47,7 +59,7 @@ def seed_db():


def init_db():
SQLModel.metadata.create_all(engine)
run_migrations()
seed_db()


Expand Down
3 changes: 2 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,5 @@ openmeteo-requests
requests-cache
retry-requests
pandas
geopy
geopy
alembic
85 changes: 85 additions & 0 deletions tests/test_migrations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
"""Tests for Alembic migration infrastructure.

Runs migrations against an in-memory SQLite database to verify:
- upgrade to head works
- downgrade to base works
- round-trip (up → down → up) works
- schema matches expected columns and foreign keys
"""

import pytest
from alembic import command
from alembic.config import Config
from sqlalchemy import create_engine, inspect

ALEMBIC_INI = "alembic.ini"


@pytest.fixture()
def alembic_engine():
return create_engine("sqlite://")


@pytest.fixture()
def alembic_cfg(alembic_engine):
cfg = Config(ALEMBIC_INI)
cfg.set_main_option("sqlalchemy.url", "sqlite://")
cfg.attributes["connection"] = alembic_engine.connect()
return cfg


def test_upgrade_head(alembic_cfg, alembic_engine):
command.upgrade(alembic_cfg, "head")

inspector = inspect(alembic_engine)
tables = inspector.get_table_names()
assert "template" in tables
assert "formsubmission" in tables
assert "alembic_version" in tables


def test_downgrade_base(alembic_cfg, alembic_engine):
command.upgrade(alembic_cfg, "head")
command.downgrade(alembic_cfg, "base")

inspector = inspect(alembic_engine)
tables = inspector.get_table_names()
assert "template" not in tables
assert "formsubmission" not in tables


def test_round_trip(alembic_cfg, alembic_engine):
command.upgrade(alembic_cfg, "head")
command.downgrade(alembic_cfg, "-1")
command.upgrade(alembic_cfg, "head")

inspector = inspect(alembic_engine)
tables = inspector.get_table_names()
assert "template" in tables
assert "formsubmission" in tables


def test_template_columns(alembic_cfg, alembic_engine):
command.upgrade(alembic_cfg, "head")

inspector = inspect(alembic_engine)
columns = {c["name"] for c in inspector.get_columns("template")}
assert columns == {"id", "name", "fields", "pdf_path", "created_at"}


def test_formsubmission_columns(alembic_cfg, alembic_engine):
command.upgrade(alembic_cfg, "head")

inspector = inspect(alembic_engine)
columns = {c["name"] for c in inspector.get_columns("formsubmission")}
assert columns == {"id", "template_id", "input_text", "output_pdf_path", "created_at"}


def test_formsubmission_fk(alembic_cfg, alembic_engine):
command.upgrade(alembic_cfg, "head")

inspector = inspect(alembic_engine)
fks = inspector.get_foreign_keys("formsubmission")
assert len(fks) == 1
assert fks[0]["referred_table"] == "template"
assert fks[0]["referred_columns"] == ["id"]