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
118 changes: 99 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,15 @@ tars/
│ │ ├── config.py # pydantic-settings Settings (env vars)
│ │ └── log_config.py # GCP-compatible JSON logging (GcpJsonFormatter)
│ ├── db/
│ │ ├── postgres.py # PostgreSQL session/engine (stub)
│ │ ├── postgres.py # SQLAlchemy engine, session factory, base ORM classes
│ │ ├── models.py # All ORM model definitions (centralised)
│ │ ├── enums.py # Shared DB enums
│ │ └── firestore.py # Firestore client (stub)
│ ├── agreement/ # Agreement bounded context
│ │ ├── models.py
│ │ ├── data/postgres/ # Repository layer
│ │ ├── domain/use_cases/ # Business logic
│ │ └── presentation/ # Routes / request handlers
│ ├── clickwrap/ # Clickwrap bounded context
│ │ ├── models.py
│ │ ├── data/postgres/
│ │ ├── domain/use_cases/
│ │ └── presentation/
Expand All @@ -31,16 +31,19 @@ tars/
│ │ ├── domain/use_cases/
│ │ └── presentation/
│ └── legal_hub/ # Legal hub bounded context
│ ├── models.py
│ ├── data/postgres/
│ ├── domain/use_cases/
│ └── presentation/
├── alembic/ # DB migrations
│ ├── env.py
│ └── versions/
├── tests/
│ ├── test_health.py
│ ├── agreement/
│ ├── clickwrap/
│ ├── consent/
│ └── legal_hub/
├── alembic.ini
├── Dockerfile
├── pyproject.toml
├── ruff.toml
Expand All @@ -54,32 +57,106 @@ tars/

### Prerequisites

- [UV](https://docs.astral.sh/uv/) — install once with:
- **[uv](https://docs.astral.sh/uv/)**Python package manager. Install once with:
```bash
curl -LsSf https://astral.sh/uv/install.sh | sh
```

### Install & Run
- **PostgreSQL** — the service uses Postgres for control-plane data. A local instance is required. With Homebrew:
```bash
brew install postgresql@16
brew services start postgresql@16
```

### 1. Install dependencies

```bash
# 1. Install Python 3.12 and project dependencies
uv python install 3.12
uv sync
```

# 2. Copy and configure environment variables
### 2. Configure environment variables

```bash
cp .env.example .env
# Edit .env as needed
```

Open `.env` and set at minimum:

```bash
DEPLOYMENT_ENV=DEV
LOG_LEVEL=INFO
DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/tars
```

See the [Environment Variables](#environment-variables) section for all options.

### 3. Create the database

# 3. Start the dev server (hot-reload)
```bash
psql -U postgres -c "CREATE DATABASE tars;"
```

### 4. Apply migrations

```bash
uv run alembic upgrade head
```

### 5. Start the dev server

```bash
uv run uvicorn app.main:app --reload
```

The service will be available at:
The service is available at:
- `http://127.0.0.1:8000/ht` — health check (Kubernetes liveness/readiness probe)
- `http://127.0.0.1:8000/docs` — Swagger UI

---

## Database Migrations

Migrations are managed with [Alembic](https://alembic.sqlalchemy.org/). The `DATABASE_URL` is read from `.env` (or the environment) — it is not set in `alembic.ini`.

### Apply all pending migrations

```bash
uv run alembic upgrade head
```

### Roll back the latest migration

```bash
uv run alembic downgrade -1
```

### Check current migration state

```bash
uv run alembic current
```

### View migration history

```bash
uv run alembic history --verbose
```

### Create a new migration

After adding or modifying an ORM model in `app/db/models.py`, autogenerate a migration:

```bash
uv run alembic revision --autogenerate -m "short_description_of_change"
```

Always review the generated file in `alembic/versions/` before committing — autogenerate can miss certain changes (e.g. check constraints, custom indexes, server defaults).

> **Note:** Migration PRs must be kept separate from feature code changes. See the team PR guidelines.

---

## Running Tests

```bash
Expand All @@ -99,30 +176,35 @@ uv run ruff format . # format

## Environment Variables

All variables are read by `app/core/config.py` via pydantic-settings. Set them in `.env` locally or as real environment variables in deployed environments. Real environment variables take precedence over `.env`.

| Variable | Default | Description |
|---|---|---|
| `DEPLOYMENT_ENV` | `DEV` | Deployment environment label (`DEV`, `QA`, `PROD`) |
| `LOG_LEVEL` | `INFO` | Python log level |
| `DEPLOYMENT_ENV` | `DEV` | Deployment environment label (`DEV`, `QA`, `PROD`). Enables SQL echo logging when set to `DEV`. |
| `LOG_LEVEL` | `INFO` | Python log level (`DEBUG`, `INFO`, `WARNING`, `ERROR`) |
| `API_V1_STR` | `/api/v1` | API version prefix |
| `DATABASE_URL` | `postgresql+asyncpg://postgres:postgres@localhost:5432/tars` | Async DSN for Postgres. Format: `postgresql+asyncpg://user:password@host:port/dbname` |
| `CLUSTER_ID` | `IN` | Cluster identifier. Used as the subdomain component in `domain_setting.default_domain` (e.g. `clickwrap.IN.spotdraft.com`) |

---

## Architecture

Tars follows a **domain-driven, layered architecture** consistent with other SpotDraft FastAPI services (oogway, tigress):
Tars follows a **domain-driven, layered architecture** consistent with other SpotDraft FastAPI services:

```
presentation/ ← FastAPI routes, request/response handling
domain/ ← Business logic (use cases, domain models)
use_cases/
data/ ← Persistence adapters (Postgres or Firestore)
models.py ← SQLAlchemy / Pydantic domain models
```

Each bounded context (`agreement`, `clickwrap`, `consent`, `legal_hub`) owns its full stack of layers independently. Cross-cutting concerns (config, logging, DB sessions) live in `app/core/` and `app/db/`.

**ORM models** are centralised in `app/db/models.py` rather than per-module files — this avoids circular imports and keeps the migration target (`Base.metadata`) in one place.

**Storage:**
- `agreement`, `clickwrap`, `legal_hub` — PostgreSQL via `app/db/postgres.py`
- `agreement`, `clickwrap`, `legal_hub` — PostgreSQL via async SQLAlchemy (`app/db/postgres.py`)
- `consent` — Firestore via `app/db/firestore.py`

---
Expand All @@ -134,9 +216,7 @@ docker build -t tars .
docker run -p 8000:8000 --env-file .env tars
```

Base image: `python:3.12-slim` (standard across SpotDraft FastAPI services).

> **Chainguard migration:** django-rest-api uses `ghcr.io/spotdraft/python-builder` (backed by `cgr.dev/chainguard-private/python:3.12-dev` with SafeDep PMG). Adopting this for Tars is tracked as a follow-up once the platform team publishes a runner image.
Base image: `python:3.12-slim`.

---

Expand Down
54 changes: 54 additions & 0 deletions alembic.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# Alembic configuration file.
# See https://alembic.sqlalchemy.org/en/latest/tutorial.html

[alembic]
# Path to the alembic scripts directory.
script_location = alembic

# Migration file template
file_template = %%(year)s%%(month)s%%(day)s_%%(rev)s_%%(slug)s

# Timezone for file timestamps
timezone = UTC

# Maximum length of revision identifiers
truncate_slug_length = 40

# The SQLAlchemy URL is intentionally **not** set here.
# env.py reads DATABASE_URL from the Settings object so .env is the
# single source of truth. If you need to pass a URL on the CLI you can
# use: alembic -x sqlalchemy.url="postgresql+asyncpg://..." upgrade head

[loggers]
keys = root, sqlalchemy, alembic

[handlers]
keys = console

[formatters]
keys = generic

[logger_root]
level = WARN
handlers = console
qualname =

[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
1 change: 1 addition & 0 deletions alembic/README
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Generic single-database configuration with an async setup.
Comment thread
AdiDev0 marked this conversation as resolved.
94 changes: 94 additions & 0 deletions alembic/env.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
"""
Alembic migration environment for the tars service.

Async-aware (asyncpg + SQLAlchemy 2.x).
Reads DATABASE_URL from app.core.config.Settings so .env is the single
source of truth — no sqlalchemy.url in alembic.ini.

Import all ORM models below the Base import so that
Base.metadata knows about them when autogenerating migrations.
"""

import asyncio
import logging
import sys
from logging.config import fileConfig
from pathlib import Path

from alembic import context
from sqlalchemy.ext.asyncio import create_async_engine

# Add project root so `app` is importable when alembic is run from repo root.
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))

# ---------------------------------------------------------------------------
# app imports — must be resolvable from the project root
# ---------------------------------------------------------------------------
from app.core.config import settings
from app.db.postgres import Base

# noqa: F401 — side-effect imports that populate Base.metadata
import app.db.models # noqa: F401

# ---------------------------------------------------------------------------
# Alembic Config
# ---------------------------------------------------------------------------
alembic_config = context.config

if alembic_config.config_file_name:
fileConfig(alembic_config.config_file_name)

target_metadata = Base.metadata

logger = logging.getLogger("alembic.env")


# ---------------------------------------------------------------------------
# Offline mode — generates SQL without a live DB connection
# ---------------------------------------------------------------------------


def run_migrations_offline() -> None:
context.configure(
url=settings.DATABASE_URL,
target_metadata=target_metadata,
Comment on lines +51 to +54

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

CLI URL override is ignored

env.py always passes settings.DATABASE_URL to both context.configure(...) and create_async_engine(...), and since it never reads context.get_x_argument(...), the documented alembic -x sqlalchemy.url=... upgrade head override in alembic.ini is ignored so migrations still target Settings.DATABASE_URL or fail when it is unset — should we prefer the -x value or drop that CLI guidance?

Severity web_search

Want Baz to fix this for you? Activate Fixer

Fix in Cursor

Prompt for AI Agents
Before applying, verify this suggestion against the current code. In alembic/env.py
around lines 46-57 (run_migrations_offline) and lines 75-80 (run_migrations_online), the
code always uses settings.DATABASE_URL and never reads Alembic -x values. Refactor by
fetching the override via context.get_x_argument (e.g., key "sqlalchemy.url"), prefer
that value when present, and fall back to settings.DATABASE_URL otherwise; then pass the
chosen URL into context.configure and create_async_engine. If you choose not to support
this override, also remove/adjust the CLI guidance that claims `alembic -x
sqlalchemy.url=...` will select the target DB, so documentation matches behavior.

literal_binds=True,
dialect_opts={"paramstyle": "named"},
compare_type=True,
compare_server_default=True,
)
with context.begin_transaction():
context.run_migrations()


# ---------------------------------------------------------------------------
# Online mode — runs migrations against a live DB
# ---------------------------------------------------------------------------


def do_run_migrations(connection) -> None: # type: ignore[no-untyped-def]
context.configure(
connection=connection,
target_metadata=target_metadata,
compare_type=True,
compare_server_default=True,
)
with context.begin_transaction():
context.run_migrations()


async def run_migrations_online() -> None:
connectable = create_async_engine(settings.DATABASE_URL, echo=False)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()


# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------

if context.is_offline_mode():
run_migrations_offline()
else:
asyncio.run(run_migrations_online())
26 changes: 26 additions & 0 deletions alembic/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"}
Empty file added alembic/versions/.gitkeep
Empty file.
Loading