diff --git a/README.md b/README.md index 0855cf5..39d00cc 100644 --- a/README.md +++ b/README.md @@ -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/ @@ -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 @@ -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 @@ -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` --- @@ -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`. --- diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..645b3f0 --- /dev/null +++ b/alembic.ini @@ -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 diff --git a/alembic/README b/alembic/README new file mode 100644 index 0000000..e1b36d8 --- /dev/null +++ b/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration with an async setup. diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..8f7ca89 --- /dev/null +++ b/alembic/env.py @@ -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, + 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()) diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..01b090c --- /dev/null +++ b/alembic/script.py.mako @@ -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"} diff --git a/alembic/versions/.gitkeep b/alembic/versions/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/alembic/versions/202677_afc3f2694ab1_create_initial_clickwrap_models.py b/alembic/versions/202677_afc3f2694ab1_create_initial_clickwrap_models.py new file mode 100644 index 0000000..06616e1 --- /dev/null +++ b/alembic/versions/202677_afc3f2694ab1_create_initial_clickwrap_models.py @@ -0,0 +1,284 @@ +"""create_initial_clickwrap_models + +Revision ID: afc3f2694ab1 +Revises: +Create Date: 2026-07-07 10:13:39.988101+00:00 +""" + +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 = 'afc3f2694ab1' +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: + # pg_trgm required for GIN trigram indexes on name_slug (packet, agreement_version). + op.execute("CREATE EXTENSION IF NOT EXISTS pg_trgm") + + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('agreement', + sa.Column('url_slug', sa.String(length=100), nullable=True), + sa.Column('header_code', sa.Text(), nullable=True, comment='Custom HTML injected into the agreement header'), + sa.Column('footer_code', sa.Text(), nullable=True, comment='Custom HTML injected into the agreement footer'), + sa.Column('is_deleted', sa.Boolean(), server_default='false', nullable=False), + sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('deleted_by_org_user_id', sa.BigInteger(), nullable=True), + sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column('workspace_id', sa.BigInteger(), nullable=False), + sa.Column('created_by_org_user_id', sa.BigInteger(), nullable=False), + sa.Column('updated_by_org_user_id', sa.BigInteger(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_index('agreement_url_slug_unique_per_workspace', 'agreement', ['workspace_id', 'url_slug'], unique=True, postgresql_where=sa.text('is_deleted = false')) + op.create_table('domain_setting', + sa.Column('custom_domain', sa.String(length=50), nullable=True), + sa.Column('custom_domain_status', sa.String(length=20), server_default='DRAFT', nullable=False), + sa.Column('default_domain', sa.String(length=50), nullable=False, comment='Set by the service from settings.CLUSTER_ID on creation. Format: clickwrap.{cluster_id}.spotdraft.com'), + sa.Column('is_deleted', sa.Boolean(), server_default='false', nullable=False), + sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('deleted_by_org_user_id', sa.BigInteger(), nullable=True), + sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column('workspace_id', sa.BigInteger(), nullable=False), + sa.Column('created_by_org_user_id', sa.BigInteger(), nullable=False), + sa.Column('updated_by_org_user_id', sa.BigInteger(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_index('domain_setting_custom_domain_idx', 'domain_setting', ['custom_domain'], unique=False) + op.create_index('domain_setting_custom_domain_unique_per_workspace', 'domain_setting', ['workspace_id', 'custom_domain'], unique=True, postgresql_where=sa.text('is_deleted = false')) + op.create_index('domain_setting_workspace_idx', 'domain_setting', ['workspace_id'], unique=False) + op.create_table('legal_hub', + sa.Column('name', sa.String(length=100), nullable=False), + sa.Column('url_slug', sa.String(length=100), nullable=False), + sa.Column('is_default', sa.Boolean(), server_default='false', nullable=False, comment="Renamed from Django's `default` field (reserved word)"), + sa.Column('is_deleted', sa.Boolean(), server_default='false', nullable=False), + sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('deleted_by_org_user_id', sa.BigInteger(), nullable=True), + sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column('workspace_id', sa.BigInteger(), nullable=False), + sa.Column('created_by_org_user_id', sa.BigInteger(), nullable=False), + sa.Column('updated_by_org_user_id', sa.BigInteger(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_index('legal_hub_name_idx', 'legal_hub', ['name'], unique=False) + # Django uses Lower(F("name")) — case-insensitive unique per workspace. + op.execute( + """ + CREATE UNIQUE INDEX legal_hub_name_unique_per_workspace + ON legal_hub (lower(name), workspace_id) + WHERE is_deleted = false + """ + ) + op.create_index('legal_hub_one_default_per_workspace', 'legal_hub', ['workspace_id'], unique=True, postgresql_where=sa.text('is_deleted = false AND is_default = true')) + op.create_index('legal_hub_slug_unique_per_workspace', 'legal_hub', ['workspace_id', 'url_slug'], unique=True, postgresql_where=sa.text('is_deleted = false')) + op.create_index('legal_hub_url_slug_idx', 'legal_hub', ['url_slug'], unique=False) + op.create_table('packet_settings', + sa.Column('agreement_ui_type', sa.String(length=50), server_default='SINGLE_CHECKBOX', nullable=False, comment='Maps ClickwrapType — controls SDK presentation'), + sa.Column('clickwrap_texts', postgresql.JSON(astext_type=sa.Text()), nullable=True), + sa.Column('whitelisted_domains', postgresql.ARRAY(sa.Text()), server_default='{}', nullable=False), + sa.Column('show_audit_click_status', sa.Boolean(), server_default='false', nullable=False), + sa.Column('send_executed_audit_email', sa.Boolean(), server_default='false', nullable=False), + sa.Column('allow_all_domains', sa.Boolean(), server_default='false', nullable=False), + sa.Column('is_deleted', sa.Boolean(), server_default='false', nullable=False), + sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('deleted_by_org_user_id', sa.BigInteger(), nullable=True), + sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column('workspace_id', sa.BigInteger(), nullable=False), + sa.Column('created_by_org_user_id', sa.BigInteger(), nullable=False), + sa.Column('updated_by_org_user_id', sa.BigInteger(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('whitelabel_config', + sa.Column('company_logo', sa.Text(), nullable=False, comment='GCS object path for company logo'), + sa.Column('logo_redirect_url', sa.Text(), nullable=True), + sa.Column('custom_styles', postgresql.JSON(astext_type=sa.Text()), nullable=False, comment='Brand CSS overrides e.g. primary_color'), + sa.Column('is_active', sa.Boolean(), server_default='true', nullable=False), + sa.Column('header_text', sa.String(length=100), nullable=True), + sa.Column('brand_name', sa.String(length=100), nullable=True), + sa.Column('add_footer', sa.Boolean(), server_default='true', nullable=False), + sa.Column('display_dropdown_and_download', sa.Boolean(), server_default='true', nullable=False), + sa.Column('display_published_agreements', sa.Boolean(), server_default='true', nullable=False), + sa.Column('favicon_icon', sa.Text(), nullable=True, comment='GCS object path for favicon (.ico)'), + sa.Column('is_deleted', sa.Boolean(), server_default='false', nullable=False), + sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('deleted_by_org_user_id', sa.BigInteger(), nullable=True), + sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column('workspace_id', sa.BigInteger(), nullable=False), + sa.Column('created_by_org_user_id', sa.BigInteger(), nullable=False), + sa.Column('updated_by_org_user_id', sa.BigInteger(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_index('whitelabel_config_unique_per_workspace', 'whitelabel_config', ['workspace_id'], unique=True, postgresql_where=sa.text('is_active = true AND is_deleted = false')) + op.create_index('whitelabel_config_workspace_is_active_idx', 'whitelabel_config', ['workspace_id', 'is_active'], unique=False) + op.create_table('agreement_version', + sa.Column('agreement_id', sa.BigInteger(), nullable=False), + sa.Column('name', sa.String(length=100), nullable=False), + sa.Column('name_slug', sa.String(length=100), nullable=False), + sa.Column('status', sa.String(length=20), nullable=True, comment='AgreementVersionStatus: DRAFT | PUBLISHED | PAST_PUBLISHED'), + sa.Column('source', sa.String(length=10), server_default='EDITOR', nullable=False, comment='AgreementVersionSource: EDIT | EDITOR | UPLOAD'), + sa.Column('html_content', sa.String(length=1000), nullable=True, comment='GCS object path for the HTML version file'), + sa.Column('pdf_document', sa.String(length=1000), nullable=True, comment='GCS object path for the PDF version file'), + sa.Column('version_number', sa.Integer(), nullable=False), + sa.Column('sub_version_number', sa.Integer(), server_default='0', nullable=False), + sa.Column('is_current', sa.Boolean(), nullable=True), + sa.Column('public_id', sa.UUID(), nullable=False), + sa.Column('modified_by_org_user_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('published_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('published_by_org_user_id', sa.BigInteger(), nullable=True), + sa.Column('is_re_acceptance_required', sa.Boolean(), server_default='false', nullable=False), + sa.Column('is_deleted', sa.Boolean(), server_default='false', nullable=False), + sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('deleted_by_org_user_id', sa.BigInteger(), nullable=True), + sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column('workspace_id', sa.BigInteger(), nullable=False), + sa.Column('created_by_org_user_id', sa.BigInteger(), nullable=False), + sa.Column('updated_by_org_user_id', sa.BigInteger(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['agreement_id'], ['agreement.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('public_id') + ) + op.create_index('agreement_version_name_slug_gin_idx', 'agreement_version', ['name_slug'], unique=False, postgresql_using='gin', postgresql_ops={'name_slug': 'gin_trgm_ops'}) + op.create_index('agreement_version_unique_draft_per_agreement', 'agreement_version', ['agreement_id'], unique=True, postgresql_where=sa.text("status = 'DRAFT' AND is_deleted = false")) + op.create_index('agreement_version_unique_published_per_agreement', 'agreement_version', ['agreement_id'], unique=True, postgresql_where=sa.text("status = 'PUBLISHED' AND is_deleted = false")) + op.create_index('agreement_version_unique_version_number', 'agreement_version', ['version_number', 'sub_version_number', 'agreement_id'], unique=True, postgresql_where=sa.text('is_deleted = false')) + op.create_table('legal_hub_agreement_mapping', + sa.Column('legal_hub_id', sa.BigInteger(), nullable=False), + sa.Column('agreement_id', sa.BigInteger(), nullable=False), + sa.Column('display_order', sa.Integer(), nullable=True, comment="Renamed from Django's `order` field (reserved SQL word)"), + sa.Column('is_deleted', sa.Boolean(), server_default='false', nullable=False), + sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('deleted_by_org_user_id', sa.BigInteger(), nullable=True), + sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column('workspace_id', sa.BigInteger(), nullable=False), + sa.Column('created_by_org_user_id', sa.BigInteger(), nullable=False), + sa.Column('updated_by_org_user_id', sa.BigInteger(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['agreement_id'], ['agreement.id'], ), + sa.ForeignKeyConstraint(['legal_hub_id'], ['legal_hub.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index('legal_hub_agreement_mapping_agreement_idx', 'legal_hub_agreement_mapping', ['agreement_id'], unique=False) + op.create_index('legal_hub_agreement_mapping_hub_idx', 'legal_hub_agreement_mapping', ['legal_hub_id'], unique=False) + op.create_index('legal_hub_agreement_mapping_unique', 'legal_hub_agreement_mapping', ['agreement_id', 'legal_hub_id'], unique=True, postgresql_where=sa.text('is_deleted = false')) + op.create_table('packet', + sa.Column('name', sa.String(length=100), nullable=False), + sa.Column('name_slug', sa.String(length=100), nullable=False), + sa.Column('description', sa.String(length=500), nullable=True), + sa.Column('public_id', sa.UUID(), nullable=False, comment='Stable public identifier exposed to SDK callers'), + sa.Column('packet_settings_id', sa.BigInteger(), nullable=False), + sa.Column('updated_by_org_user_at', sa.DateTime(timezone=True), nullable=True, comment='Last time an org user explicitly saved changes'), + sa.Column('is_deleted', sa.Boolean(), server_default='false', nullable=False), + sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('deleted_by_org_user_id', sa.BigInteger(), nullable=True), + sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column('workspace_id', sa.BigInteger(), nullable=False), + sa.Column('created_by_org_user_id', sa.BigInteger(), nullable=False), + sa.Column('updated_by_org_user_id', sa.BigInteger(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['packet_settings_id'], ['packet_settings.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('packet_settings_id'), + sa.UniqueConstraint('public_id') + ) + op.create_index('packet_name_slug_gin_idx', 'packet', ['name_slug'], unique=False, postgresql_using='gin', postgresql_ops={'name_slug': 'gin_trgm_ops'}) + op.create_index('packet_name_unique_per_workspace', 'packet', ['workspace_id', 'name_slug'], unique=True, postgresql_where=sa.text('is_deleted = false')) + op.create_index('packet_workspace_idx', 'packet', ['workspace_id'], unique=False) + op.create_table('legal_hub_custom_url_mapping', + sa.Column('custom_uri', sa.String(length=100), nullable=False), + sa.Column('legal_hub_agreement_mapping_id', sa.BigInteger(), nullable=True), + sa.Column('is_deleted', sa.Boolean(), server_default='false', nullable=False), + sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('deleted_by_org_user_id', sa.BigInteger(), nullable=True), + sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column('workspace_id', sa.BigInteger(), nullable=False), + sa.Column('created_by_org_user_id', sa.BigInteger(), nullable=False), + sa.Column('updated_by_org_user_id', sa.BigInteger(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['legal_hub_agreement_mapping_id'], ['legal_hub_agreement_mapping.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index('legal_hub_custom_url_mapping_uri_idx', 'legal_hub_custom_url_mapping', ['custom_uri'], unique=False) + op.create_index('legal_hub_custom_url_unique_per_agreement', 'legal_hub_custom_url_mapping', ['custom_uri', 'legal_hub_agreement_mapping_id'], unique=True, postgresql_where=sa.text('is_deleted = false')) + op.create_table('packet_agreement_mapping', + sa.Column('packet_id', sa.BigInteger(), nullable=False), + sa.Column('agreement_id', sa.BigInteger(), nullable=False), + sa.Column('is_deleted', sa.Boolean(), server_default='false', nullable=False), + sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('deleted_by_org_user_id', sa.BigInteger(), nullable=True), + sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column('workspace_id', sa.BigInteger(), nullable=False), + sa.Column('created_by_org_user_id', sa.BigInteger(), nullable=False), + sa.Column('updated_by_org_user_id', sa.BigInteger(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['agreement_id'], ['agreement.id'], ), + sa.ForeignKeyConstraint(['packet_id'], ['packet.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index('packet_agreement_mapping_agreement_idx', 'packet_agreement_mapping', ['agreement_id'], unique=False) + op.create_index('packet_agreement_mapping_packet_idx', 'packet_agreement_mapping', ['packet_id'], unique=False) + op.create_index('packet_agreement_mapping_unique', 'packet_agreement_mapping', ['packet_id', 'agreement_id'], unique=True, postgresql_where=sa.text('is_deleted = false')) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index('packet_agreement_mapping_unique', table_name='packet_agreement_mapping', postgresql_where=sa.text('is_deleted = false')) + op.drop_index('packet_agreement_mapping_packet_idx', table_name='packet_agreement_mapping') + op.drop_index('packet_agreement_mapping_agreement_idx', table_name='packet_agreement_mapping') + op.drop_table('packet_agreement_mapping') + op.drop_index('legal_hub_custom_url_unique_per_agreement', table_name='legal_hub_custom_url_mapping', postgresql_where=sa.text('is_deleted = false')) + op.drop_index('legal_hub_custom_url_mapping_uri_idx', table_name='legal_hub_custom_url_mapping') + op.drop_table('legal_hub_custom_url_mapping') + op.drop_index('packet_workspace_idx', table_name='packet') + op.drop_index('packet_name_unique_per_workspace', table_name='packet', postgresql_where=sa.text('is_deleted = false')) + op.drop_index('packet_name_slug_gin_idx', table_name='packet', postgresql_using='gin', postgresql_ops={'name_slug': 'gin_trgm_ops'}) + op.drop_table('packet') + op.drop_index('legal_hub_agreement_mapping_unique', table_name='legal_hub_agreement_mapping', postgresql_where=sa.text('is_deleted = false')) + op.drop_index('legal_hub_agreement_mapping_hub_idx', table_name='legal_hub_agreement_mapping') + op.drop_index('legal_hub_agreement_mapping_agreement_idx', table_name='legal_hub_agreement_mapping') + op.drop_table('legal_hub_agreement_mapping') + op.drop_index('agreement_version_unique_version_number', table_name='agreement_version', postgresql_where=sa.text('is_deleted = false')) + op.drop_index('agreement_version_unique_published_per_agreement', table_name='agreement_version', postgresql_where=sa.text("status = 'PUBLISHED' AND is_deleted = false")) + op.drop_index('agreement_version_unique_draft_per_agreement', table_name='agreement_version', postgresql_where=sa.text("status = 'DRAFT' AND is_deleted = false")) + op.drop_index('agreement_version_name_slug_gin_idx', table_name='agreement_version', postgresql_using='gin', postgresql_ops={'name_slug': 'gin_trgm_ops'}) + op.drop_table('agreement_version') + op.drop_index('whitelabel_config_workspace_is_active_idx', table_name='whitelabel_config') + op.drop_index('whitelabel_config_unique_per_workspace', table_name='whitelabel_config', postgresql_where=sa.text('is_active = true AND is_deleted = false')) + op.drop_table('whitelabel_config') + op.drop_table('packet_settings') + op.drop_index('legal_hub_url_slug_idx', table_name='legal_hub') + op.drop_index('legal_hub_slug_unique_per_workspace', table_name='legal_hub', postgresql_where=sa.text('is_deleted = false')) + op.drop_index('legal_hub_one_default_per_workspace', table_name='legal_hub', postgresql_where=sa.text('is_deleted = false AND is_default = true')) + op.drop_index('legal_hub_name_unique_per_workspace', table_name='legal_hub') + op.drop_index('legal_hub_name_idx', table_name='legal_hub') + op.drop_table('legal_hub') + op.drop_index('domain_setting_workspace_idx', table_name='domain_setting') + op.drop_index('domain_setting_custom_domain_unique_per_workspace', table_name='domain_setting', postgresql_where=sa.text('is_deleted = false')) + op.drop_index('domain_setting_custom_domain_idx', table_name='domain_setting') + op.drop_table('domain_setting') + op.drop_index('agreement_url_slug_unique_per_workspace', table_name='agreement', postgresql_where=sa.text('is_deleted = false')) + op.drop_table('agreement') + op.execute("DROP EXTENSION IF EXISTS pg_trgm") + # ### end Alembic commands ### diff --git a/app/core/config.py b/app/core/config.py index 1c382c7..b9b446e 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -8,5 +8,12 @@ class Settings(BaseSettings): LOG_LEVEL: str = "INFO" API_V1_STR: str = "/api/v1" + # Postgres — async DSN (asyncpg driver) + # Format: postgresql+asyncpg://user:password@host:port/dbname + DATABASE_URL: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/tars" + + # Used as the default value for domain_setting.default_domain + CLUSTER_ID: str = "IN" + settings = Settings() diff --git a/app/db/enums.py b/app/db/enums.py new file mode 100644 index 0000000..cc2151a --- /dev/null +++ b/app/db/enums.py @@ -0,0 +1,32 @@ +from enum import StrEnum + + +class AgreementUiType(StrEnum): + """Maps Django's ClickwrapType. Controls the UI presentation of the packet.""" + + SINGLE_CHECKBOX = "SINGLE_CHECKBOX" + MULTIPLE_CHECKBOX = "MULTIPLE_CHECKBOX" + INLINE = "INLINE" + + +class DomainStatusType(StrEnum): + """Maps Django's ClickwrapDomainStatusType. Verification state of a custom domain.""" + + DRAFT = "DRAFT" + VERIFIED = "VERIFIED" + + +class AgreementVersionStatus(StrEnum): + """Maps Django's ClickwrapAgreementVersionStatusType.""" + + DRAFT = "DRAFT" + PUBLISHED = "PUBLISHED" + PAST_PUBLISHED = "PAST_PUBLISHED" + + +class AgreementVersionSource(StrEnum): + """Maps Django's ClickwrapAgreementVersionSourceType. How the version content was created.""" + + EDIT = "EDIT" + EDITOR = "EDITOR" + UPLOAD = "UPLOAD" diff --git a/app/db/models.py b/app/db/models.py new file mode 100644 index 0000000..2ac05d1 --- /dev/null +++ b/app/db/models.py @@ -0,0 +1,476 @@ +""" +Postgres ORM models for the tars control-plane. + +All 10 tables live in this single file — same pattern as Django's per-app +models.py. Module packages (clickwrap/, agreement/, legal_hub/) import from +here for queries; they do not define their own ORM models. + +Table mapping from Django clickwraps/models.py: + Clickwrap -> packet + ClickwrapSettings -> packet_settings + ClickwrapDomainSetting -> domain_setting + ClickwrapAgreementWhitelabelConfig -> whitelabel_config + ClickwrapAgreementMapping -> packet_agreement_mapping + ClickwrapAgreement -> agreement + ClickwrapAgreementVersion -> agreement_version + ClickwrapLegalHub -> legal_hub + ClickwrapLegalHubAgreementMapping -> legal_hub_agreement_mapping + ClickwrapLegalHubAgreementCustomURLMapping -> legal_hub_custom_url_mapping + +Not migrated (replaced by Firestore): + ClickwrapConsent, ClickwrapUser, ClickwrapConsentAgreementVersionMapping + +FK policy: + - Tables defined in this file use DB-level ForeignKey constraints. + - Cross-service references (workspace_id, org_user_id, etc.) are raw + BigInteger columns — integrity enforced at the application layer. +""" + +import uuid +from datetime import datetime + +from sqlalchemy import ( + BigInteger, + Boolean, + DateTime, + ForeignKey, + Index, + Integer, + String, + Text, + func, + text, +) +from sqlalchemy.dialects.postgresql import ARRAY, JSON, UUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.enums import ( + AgreementUiType, + AgreementVersionSource, + AgreementVersionStatus, + DomainStatusType, +) +from app.db.postgres import RuntimeBaseModel, SoftDeleteMixin + +# --------------------------------------------------------------------------- +# packet_settings (Django: ClickwrapSettings) +# Holds UI / domain configuration for a packet. Created before the packet +# and referenced via a FK on packet. 1:1 relationship. +# --------------------------------------------------------------------------- + + +class PacketSettings(SoftDeleteMixin, RuntimeBaseModel): + __tablename__ = "packet_settings" + + agreement_ui_type: Mapped[str] = mapped_column( + String(50), + nullable=False, + default=AgreementUiType.SINGLE_CHECKBOX, + server_default=AgreementUiType.SINGLE_CHECKBOX, + comment="Maps ClickwrapType — controls SDK presentation", + ) + clickwrap_texts: Mapped[dict | None] = mapped_column(JSON, nullable=True) + whitelisted_domains: Mapped[list] = mapped_column( + ARRAY(Text), nullable=False, server_default="{}" + ) + show_audit_click_status: Mapped[bool] = mapped_column( + Boolean, nullable=False, default=False, server_default="false" + ) + send_executed_audit_email: Mapped[bool] = mapped_column( + Boolean, nullable=False, default=False, server_default="false" + ) + allow_all_domains: Mapped[bool] = mapped_column( + Boolean, nullable=False, default=False, server_default="false" + ) + + +# --------------------------------------------------------------------------- +# packet (Django: Clickwrap) +# Top-level container. Holds a reference to its settings via packet_settings_id. +# contract_type FK removed per design decision. +# --------------------------------------------------------------------------- + + +class Packet(SoftDeleteMixin, RuntimeBaseModel): + __tablename__ = "packet" + + name: Mapped[str] = mapped_column(String(100), nullable=False) + name_slug: Mapped[str] = mapped_column(String(100), nullable=False) + description: Mapped[str | None] = mapped_column(String(500), nullable=True) + public_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + nullable=False, + unique=True, + default=uuid.uuid4, + comment="Stable public identifier exposed to SDK callers", + ) + packet_settings_id: Mapped[int] = mapped_column( + BigInteger, ForeignKey("packet_settings.id"), nullable=False, unique=True + ) + updated_by_org_user_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), + nullable=True, + comment="Last time an org user explicitly saved changes", + ) + + __table_args__ = ( + Index( + "packet_name_unique_per_workspace", + "workspace_id", + "name_slug", + unique=True, + postgresql_where=text("is_deleted = false"), + ), + Index("packet_workspace_idx", "workspace_id"), + Index( + "packet_name_slug_gin_idx", + "name_slug", + postgresql_using="gin", + postgresql_ops={"name_slug": "gin_trgm_ops"}, + ), + ) + + +# --------------------------------------------------------------------------- +# domain_setting (Django: ClickwrapDomainSetting) +# Custom domain verification record per workspace. +# --------------------------------------------------------------------------- + + +class DomainSetting(SoftDeleteMixin, RuntimeBaseModel): + __tablename__ = "domain_setting" + + custom_domain: Mapped[str | None] = mapped_column(String(50), nullable=True) + custom_domain_status: Mapped[str] = mapped_column( + String(20), + nullable=False, + default=DomainStatusType.DRAFT, + server_default=DomainStatusType.DRAFT, + ) + default_domain: Mapped[str] = mapped_column( + String(50), + nullable=False, + comment=( + "Set by the service from settings.CLUSTER_ID on creation. " + "Format: clickwrap.{cluster_id}.spotdraft.com" + ), + ) + + __table_args__ = ( + # Maps Django's custom_domain_unique_per_workspace + Index( + "domain_setting_custom_domain_unique_per_workspace", + "workspace_id", + "custom_domain", + unique=True, + postgresql_where=text("is_deleted = false"), + ), + # Maps Django's cwd_index_workspace_index + Index("domain_setting_workspace_idx", "workspace_id"), + # Maps Django's cwd_index_custom_domain + Index("domain_setting_custom_domain_idx", "custom_domain"), + ) + + +# --------------------------------------------------------------------------- +# whitelabel_config (Django: ClickwrapAgreementWhitelabelConfig) +# GCS paths stored as Text — same as what Django FileField persists in the DB. +# Upload logic and favicon validation live in the use-case / Pydantic layer. +# --------------------------------------------------------------------------- + + +class WhitelabelConfig(SoftDeleteMixin, RuntimeBaseModel): + __tablename__ = "whitelabel_config" + + company_logo: Mapped[str] = mapped_column( + Text, nullable=False, comment="GCS object path for company logo" + ) + logo_redirect_url: Mapped[str | None] = mapped_column(Text, nullable=True) + custom_styles: Mapped[dict] = mapped_column( + JSON, nullable=False, comment="Brand CSS overrides e.g. primary_color" + ) + is_active: Mapped[bool] = mapped_column( + Boolean, nullable=False, default=True, server_default="true" + ) + header_text: Mapped[str | None] = mapped_column( + String(100), nullable=True, default="Legal Hub" + ) + brand_name: Mapped[str | None] = mapped_column(String(100), nullable=True) + add_footer: Mapped[bool] = mapped_column( + Boolean, nullable=False, default=True, server_default="true" + ) + display_dropdown_and_download: Mapped[bool] = mapped_column( + Boolean, nullable=False, default=True, server_default="true" + ) + display_published_agreements: Mapped[bool] = mapped_column( + Boolean, nullable=False, default=True, server_default="true" + ) + favicon_icon: Mapped[str | None] = mapped_column( + Text, nullable=True, comment="GCS object path for favicon (.ico)" + ) + + __table_args__ = ( + Index( + "whitelabel_config_unique_per_workspace", + "workspace_id", + unique=True, + postgresql_where=text("is_active = true AND is_deleted = false"), + ), + Index("whitelabel_config_workspace_is_active_idx", "workspace_id", "is_active"), + ) + + +# --------------------------------------------------------------------------- +# agreement (Django: ClickwrapAgreement) +# Reusable agreement entity. Can belong to multiple packets via +# packet_agreement_mapping. +# --------------------------------------------------------------------------- + + +class Agreement(SoftDeleteMixin, RuntimeBaseModel): + __tablename__ = "agreement" + + url_slug: Mapped[str | None] = mapped_column(String(100), nullable=True) + header_code: Mapped[str | None] = mapped_column( + Text, nullable=True, comment="Custom HTML injected into the agreement header" + ) + footer_code: Mapped[str | None] = mapped_column( + Text, nullable=True, comment="Custom HTML injected into the agreement footer" + ) + + __table_args__ = ( + Index( + "agreement_url_slug_unique_per_workspace", + "workspace_id", + "url_slug", + unique=True, + postgresql_where=text("is_deleted = false"), + ), + ) + + +# --------------------------------------------------------------------------- +# packet_agreement_mapping (Django: ClickwrapAgreementMapping) +# Junction table linking packets to their agreements. +# --------------------------------------------------------------------------- + + +class PacketAgreementMapping(SoftDeleteMixin, RuntimeBaseModel): + __tablename__ = "packet_agreement_mapping" + + packet_id: Mapped[int] = mapped_column( + BigInteger, ForeignKey("packet.id"), nullable=False + ) + agreement_id: Mapped[int] = mapped_column( + BigInteger, ForeignKey("agreement.id"), nullable=False + ) + + __table_args__ = ( + Index( + "packet_agreement_mapping_unique", + "packet_id", + "agreement_id", + unique=True, + postgresql_where=text("is_deleted = false"), + ), + Index("packet_agreement_mapping_packet_idx", "packet_id"), + Index("packet_agreement_mapping_agreement_idx", "agreement_id"), + ) + + +# --------------------------------------------------------------------------- +# agreement_version (Django: ClickwrapAgreementVersion) +# Immutable content snapshot for an agreement. html_content / pdf_document +# are GCS object paths (same storage pattern as Django FileField). +# --------------------------------------------------------------------------- + + +class AgreementVersion(SoftDeleteMixin, RuntimeBaseModel): + __tablename__ = "agreement_version" + + agreement_id: Mapped[int] = mapped_column( + BigInteger, ForeignKey("agreement.id"), nullable=False + ) + name: Mapped[str] = mapped_column(String(100), nullable=False) + name_slug: Mapped[str] = mapped_column(String(100), nullable=False) + status: Mapped[str | None] = mapped_column( + String(20), + nullable=True, + comment="AgreementVersionStatus: DRAFT | PUBLISHED | PAST_PUBLISHED", + ) + source: Mapped[str] = mapped_column( + String(10), + nullable=False, + default=AgreementVersionSource.EDITOR, + server_default=AgreementVersionSource.EDITOR, + comment="AgreementVersionSource: EDIT | EDITOR | UPLOAD", + ) + # GCS object paths — equivalent to Django FileField column values + html_content: Mapped[str | None] = mapped_column( + String(1000), nullable=True, comment="GCS object path for the HTML version file" + ) + pdf_document: Mapped[str | None] = mapped_column( + String(1000), nullable=True, comment="GCS object path for the PDF version file" + ) + version_number: Mapped[int] = mapped_column(Integer, nullable=False) + sub_version_number: Mapped[int] = mapped_column( + Integer, nullable=False, default=0, server_default="0" + ) + is_current: Mapped[bool | None] = mapped_column(Boolean, nullable=True) + public_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + nullable=False, + unique=True, + default=uuid.uuid4, + ) + modified_by_org_user_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + # Raw bigint — references OrganizationUser which lives in Django, not here + published_by_org_user_id: Mapped[int | None] = mapped_column( + BigInteger, nullable=True + ) + is_re_acceptance_required: Mapped[bool] = mapped_column( + Boolean, nullable=False, default=False, server_default="false" + ) + + __table_args__ = ( + Index( + "agreement_version_unique_version_number", + "version_number", + "sub_version_number", + "agreement_id", + unique=True, + postgresql_where=text("is_deleted = false"), + ), + # Maps Django's unique_status_equals_published_per_agreement + Index( + "agreement_version_unique_published_per_agreement", + "agreement_id", + unique=True, + postgresql_where=text("status = 'PUBLISHED' AND is_deleted = false"), + ), + # Maps Django's unique_status_equals_draft_per_agreement + Index( + "agreement_version_unique_draft_per_agreement", + "agreement_id", + unique=True, + postgresql_where=text("status = 'DRAFT' AND is_deleted = false"), + ), + # Maps Django's cw_agg_ver_name_slug_gin_index + # Requires pg_trgm extension (enabled in migration) + Index( + "agreement_version_name_slug_gin_idx", + "name_slug", + postgresql_using="gin", + postgresql_ops={"name_slug": "gin_trgm_ops"}, + ), + ) + + +# --------------------------------------------------------------------------- +# legal_hub (Django: ClickwrapLegalHub) +# Curated collection of agreements surfaced as a hosted page. +# --------------------------------------------------------------------------- + + +class LegalHub(SoftDeleteMixin, RuntimeBaseModel): + __tablename__ = "legal_hub" + + name: Mapped[str] = mapped_column(String(100), nullable=False) + url_slug: Mapped[str] = mapped_column(String(100), nullable=False) + is_default: Mapped[bool] = mapped_column( + Boolean, + nullable=False, + default=False, + server_default="false", + comment="Renamed from Django's `default` field (reserved word)", + ) + + __table_args__ = ( + Index( + "legal_hub_slug_unique_per_workspace", + "workspace_id", + "url_slug", + unique=True, + postgresql_where=text("is_deleted = false"), + ), + # Maps Django's lh_name_unique_per_workspace (Lower(F("name"))) + Index( + "legal_hub_name_unique_per_workspace", + func.lower(name), + "workspace_id", + unique=True, + postgresql_where=text("is_deleted = false"), + ), + # Maps Django's lh_one_default_per_workspace + Index( + "legal_hub_one_default_per_workspace", + "workspace_id", + unique=True, + postgresql_where=text("is_deleted = false AND is_default = true"), + ), + # Maps Django's legal_hub_name_index + Index("legal_hub_name_idx", "name"), + # Maps Django's legal_hub_url_slug_index + Index("legal_hub_url_slug_idx", "url_slug"), + ) + + +# --------------------------------------------------------------------------- +# legal_hub_agreement_mapping (Django: ClickwrapLegalHubAgreementMapping) +# Ordered list of agreements within a legal hub. +# --------------------------------------------------------------------------- + + +class LegalHubAgreementMapping(SoftDeleteMixin, RuntimeBaseModel): + __tablename__ = "legal_hub_agreement_mapping" + + legal_hub_id: Mapped[int] = mapped_column( + BigInteger, ForeignKey("legal_hub.id"), nullable=False + ) + agreement_id: Mapped[int] = mapped_column( + BigInteger, ForeignKey("agreement.id"), nullable=False + ) + display_order: Mapped[int | None] = mapped_column( + Integer, + nullable=True, + comment="Renamed from Django's `order` field (reserved SQL word)", + ) + + __table_args__ = ( + Index( + "legal_hub_agreement_mapping_unique", + "agreement_id", + "legal_hub_id", + unique=True, + postgresql_where=text("is_deleted = false"), + ), + Index("legal_hub_agreement_mapping_hub_idx", "legal_hub_id"), + Index("legal_hub_agreement_mapping_agreement_idx", "agreement_id"), + ) + + +# --------------------------------------------------------------------------- +# legal_hub_custom_url_mapping (Django: ClickwrapLegalHubAgreementCustomURLMapping) +# Custom URI routing for individual agreements within a legal hub. +# --------------------------------------------------------------------------- + + +class LegalHubCustomUrlMapping(SoftDeleteMixin, RuntimeBaseModel): + __tablename__ = "legal_hub_custom_url_mapping" + + custom_uri: Mapped[str] = mapped_column(String(100), nullable=False) + legal_hub_agreement_mapping_id: Mapped[int | None] = mapped_column( + BigInteger, ForeignKey("legal_hub_agreement_mapping.id"), nullable=True + ) + + __table_args__ = ( + Index( + "legal_hub_custom_url_unique_per_agreement", + "custom_uri", + "legal_hub_agreement_mapping_id", + unique=True, + postgresql_where=text("is_deleted = false"), + ), + Index("legal_hub_custom_url_mapping_uri_idx", "custom_uri"), + ) diff --git a/app/db/postgres.py b/app/db/postgres.py index e69de29..f299064 100644 --- a/app/db/postgres.py +++ b/app/db/postgres.py @@ -0,0 +1,85 @@ +from datetime import datetime + +from sqlalchemy import BigInteger, Boolean, DateTime, func +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + +from app.core.config import settings + +# --------------------------------------------------------------------------- +# Engine + session factory +# --------------------------------------------------------------------------- + +engine = create_async_engine( + settings.DATABASE_URL, + echo=settings.DEPLOYMENT_ENV == "DEV", + pool_pre_ping=True, +) + +AsyncSessionLocal: async_sessionmaker[AsyncSession] = async_sessionmaker( + bind=engine, + expire_on_commit=False, + autoflush=False, + autocommit=False, +) + + +# --------------------------------------------------------------------------- +# Declarative base — all ORM models must inherit from this +# --------------------------------------------------------------------------- + + +class Base(DeclarativeBase): + pass + + +# --------------------------------------------------------------------------- +# RuntimeBaseModel +# Every control-plane table inherits from this. +# workspace_id is a raw bigint — no FK to Django's CompanyProfile. +# created_by_org_user_id / updated_by_org_user_id are raw bigints — no FK. +# --------------------------------------------------------------------------- + + +class RuntimeBaseModel(Base): + __abstract__ = True + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + + workspace_id: Mapped[int] = mapped_column(BigInteger, nullable=False) + + created_by_org_user_id: Mapped[int] = mapped_column(BigInteger, nullable=False) + updated_by_org_user_id: Mapped[int | None] = mapped_column( + BigInteger, nullable=True + ) + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + server_default=func.now(), + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + server_default=func.now(), + onupdate=func.now(), + ) + + +# --------------------------------------------------------------------------- +# SoftDeleteMixin +# Composed onto admin-managed entities that support soft delete. +# Not used on append-only / immutable tables. +# --------------------------------------------------------------------------- + + +class SoftDeleteMixin: + is_deleted: Mapped[bool] = mapped_column( + Boolean, nullable=False, default=False, server_default="false" + ) + deleted_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + deleted_by_org_user_id: Mapped[int | None] = mapped_column( + BigInteger, nullable=True + ) diff --git a/pyproject.toml b/pyproject.toml index c8f61bd..828b7f5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,11 @@ dependencies = [ # Structured JSON logging (needed from first deployment for GCP Log Explorer) "python-json-logger>=3.2.0", + + # Database — Postgres ORM + async driver + migrations + "SQLAlchemy[asyncio]==2.0.48", + "asyncpg==0.31.0", + "alembic==1.18.4", ] [dependency-groups] diff --git a/uv.lock b/uv.lock index 1727a52..1269111 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,20 @@ version = 1 revision = 3 requires-python = "==3.12.*" +[[package]] +name = "alembic" +version = "1.18.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mako" }, + { name = "sqlalchemy" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/94/13/8b084e0f2efb0275a1d534838844926f798bd766566b1375174e2448cd31/alembic-1.18.4.tar.gz", hash = "sha256:cb6e1fd84b6174ab8dbb2329f86d631ba9559dd78df550b57804d607672cedbc", size = 2056725, upload-time = "2026-02-10T16:00:47.195Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/29/6533c317b74f707ea28f8d633734dbda2119bbadfc61b2f3640ba835d0f7/alembic-1.18.4-py3-none-any.whl", hash = "sha256:a5ed4adcf6d8a4cb575f3d759f071b03cd6e5c7618eb796cb52497be25bfe19a", size = 263893, upload-time = "2026-02-10T16:00:49.997Z" }, +] + [[package]] name = "annotated-doc" version = "0.0.4" @@ -58,6 +72,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/66/40/c53deb2cd0c9b0fb636d24d9f40924cf2e65028e6b20b10cd5c1eeb2c730/ast_serialize-0.6.0-cp39-abi3-win_arm64.whl", hash = "sha256:ccd132fe8db56f61fe743b1f644d01b8d65b83248a8da506f3132bda86d6ed5e", size = 1072965, upload-time = "2026-06-30T20:02:54.097Z" }, ] +[[package]] +name = "asyncpg" +version = "0.31.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/cc/d18065ce2380d80b1bcce927c24a2642efd38918e33fd724bc4bca904877/asyncpg-0.31.0.tar.gz", hash = "sha256:c989386c83940bfbd787180f2b1519415e2d3d6277a70d9d0f0145ac73500735", size = 993667, upload-time = "2025-11-24T23:27:00.812Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/a6/59d0a146e61d20e18db7396583242e32e0f120693b67a8de43f1557033e2/asyncpg-0.31.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b44c31e1efc1c15188ef183f287c728e2046abb1d26af4d20858215d50d91fad", size = 662042, upload-time = "2025-11-24T23:25:49.578Z" }, + { url = "https://files.pythonhosted.org/packages/36/01/ffaa189dcb63a2471720615e60185c3f6327716fdc0fc04334436fbb7c65/asyncpg-0.31.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0c89ccf741c067614c9b5fc7f1fc6f3b61ab05ae4aaa966e6fd6b93097c7d20d", size = 638504, upload-time = "2025-11-24T23:25:51.501Z" }, + { url = "https://files.pythonhosted.org/packages/9f/62/3f699ba45d8bd24c5d65392190d19656d74ff0185f42e19d0bbd973bb371/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:12b3b2e39dc5470abd5e98c8d3373e4b1d1234d9fbdedf538798b2c13c64460a", size = 3426241, upload-time = "2025-11-24T23:25:53.278Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d1/a867c2150f9c6e7af6462637f613ba67f78a314b00db220cd26ff559d532/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:aad7a33913fb8bcb5454313377cc330fbb19a0cd5faa7272407d8a0c4257b671", size = 3520321, upload-time = "2025-11-24T23:25:54.982Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1a/cce4c3f246805ecd285a3591222a2611141f1669d002163abef999b60f98/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3df118d94f46d85b2e434fd62c84cb66d5834d5a890725fe625f498e72e4d5ec", size = 3316685, upload-time = "2025-11-24T23:25:57.43Z" }, + { url = "https://files.pythonhosted.org/packages/40/ae/0fc961179e78cc579e138fad6eb580448ecae64908f95b8cb8ee2f241f67/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bd5b6efff3c17c3202d4b37189969acf8927438a238c6257f66be3c426beba20", size = 3471858, upload-time = "2025-11-24T23:25:59.636Z" }, + { url = "https://files.pythonhosted.org/packages/52/b2/b20e09670be031afa4cbfabd645caece7f85ec62d69c312239de568e058e/asyncpg-0.31.0-cp312-cp312-win32.whl", hash = "sha256:027eaa61361ec735926566f995d959ade4796f6a49d3bde17e5134b9964f9ba8", size = 527852, upload-time = "2025-11-24T23:26:01.084Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f0/f2ed1de154e15b107dc692262395b3c17fc34eafe2a78fc2115931561730/asyncpg-0.31.0-cp312-cp312-win_amd64.whl", hash = "sha256:72d6bdcbc93d608a1158f17932de2321f68b1a967a13e014998db87a72ed3186", size = 597175, upload-time = "2025-11-24T23:26:02.564Z" }, +] + [[package]] name = "certifi" version = "2026.6.17" @@ -90,7 +120,7 @@ wheels = [ [[package]] name = "fastapi" -version = "0.139.0" +version = "0.138.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, @@ -99,9 +129,27 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d3/af/a5f50ccfa659ec1802cb4ca842c23f06d906a8cc9aef6016a2caeea3d4ed/fastapi-0.139.0.tar.gz", hash = "sha256:99ab7b2d92223c76d6cf10757ab3f89d45b38267fc20b2a136cf02f6beac3145", size = 423016, upload-time = "2026-07-01T16:35:33.436Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0c/a9/9f8f7e00195c29836e9bf58bbbaf579e29878b8a67851efff93d9b6d4eb7/fastapi-0.138.2.tar.gz", hash = "sha256:6432359d067a432134620e7c5e4c6e5063e7f37815bbbbf20acef14b0d2e3fc8", size = 420423, upload-time = "2026-06-29T12:44:12.556Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/b3/38be2c074bdd0c986340db1d72d7b2321b805b1c5a68069aa00b5d31fd02/fastapi-0.138.2-py3-none-any.whl", hash = "sha256:db90c1ffb5517fba5d4a9f80e866daa008747e646310c9ce155c8c535f9d1615", size = 129271, upload-time = "2026-06-29T12:44:13.905Z" }, +] + +[[package]] +name = "greenlet" +version = "3.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e2/f1/fbbfef6af0bad0548f09bc28948ea3c275b4edb19e17fc5ca9900a6a634d/greenlet-3.5.3.tar.gz", hash = "sha256:a61efc018fd3eb317eeca31aba90ee9e7f26f22884a79b6c6ec715bf71bb62f1", size = 200270, upload-time = "2026-06-26T19:28:24.832Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9e/7c/8e3c6ad324ea5cb36604fc3f968554887891c316d9dfde57761611d907ad/fastapi-0.139.0-py3-none-any.whl", hash = "sha256:cf15e1e9e667ddb0ad63811e60bd11390d1aac838ca4a7a23f421807b2308189", size = 130339, upload-time = "2026-07-01T16:35:32.19Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6e/4c37d51a2b7f82d2ff11bb6b5f7d766d9a011726624af255e843727627a3/greenlet-3.5.3-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:719757059f5a53fd0dde23f78cffeafcdd97b21c850ddb7ca684a3c1a1f122e2", size = 288685, upload-time = "2026-06-26T18:22:08.977Z" }, + { url = "https://files.pythonhosted.org/packages/7a/73/815dd90131c1b71ebdf53dbc7c276cafec2a1173b97559f97aba72724a87/greenlet-3.5.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:efa9f765dd09f9d0cdac651ffdf631ee59ec5dc6ee7a73e0c012ba9c52fbdf5b", size = 604761, upload-time = "2026-06-26T19:07:10.114Z" }, + { url = "https://files.pythonhosted.org/packages/9f/57/079cfe76bcef36b153b25607ee91c6fcb58f17f8b23c86bbbeabe0c88d72/greenlet-3.5.3-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7faba15ac005376e02a0384504e0243be3370ce010296a44a820feb342b505ab", size = 617044, upload-time = "2026-06-26T19:10:07.25Z" }, + { url = "https://files.pythonhosted.org/packages/fb/fb/d97dc261209c80744b7c8132693a30d70ec6e7315e632cb0a10b3fec94dd/greenlet-3.5.3-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5795cd1101371140551c645f2d408b8d3c01a5a29cf8a9bce6e759c983682d23", size = 622351, upload-time = "2026-06-26T19:24:16.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/87/b4d095775a3fb1bcafbb483fc206b27ebb785724c83051447737085dc54e/greenlet-3.5.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:87142215824be6ac05e2e8e2786eec307ccbc27c36723c3881959df654af6861", size = 614244, upload-time = "2026-06-26T18:32:17.594Z" }, + { url = "https://files.pythonhosted.org/packages/8e/ac/e5fee13cbbd0e8de312d9a146584b8a51891c68847330ef9dc8b5109d23f/greenlet-3.5.3-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:af4923b3096e26a36d7e9cf24ab88083a20f97d191e3b97f253731ce9b41b28c", size = 425395, upload-time = "2026-06-26T19:25:37.144Z" }, + { url = "https://files.pythonhosted.org/packages/8a/70/7559b609683650fa2b95b8ab84b4ab0b26556a635d19675e12aa832d826d/greenlet-3.5.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:215275b1b49320987352e6c1b054acca0064f965a2c66992bed9a6f7d913f149", size = 1574210, upload-time = "2026-06-26T19:09:03.077Z" }, + { url = "https://files.pythonhosted.org/packages/ae/73/be55392074c60fc37655ca40fa6022457bfbf6718e9e342a7b0b41f96dd2/greenlet-3.5.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6b1b0eed82364b0e32c4ea0f221452d33e6bb17ae094d9f72aed9851812747ea", size = 1638627, upload-time = "2026-06-26T18:31:44.748Z" }, + { url = "https://files.pythonhosted.org/packages/14/40/c57489acf8e37d74e2913d4eff63aa0dba17acccc4bdeef874dde2dbbec9/greenlet-3.5.3-cp312-cp312-win_amd64.whl", hash = "sha256:cde8adafa2365676f74a979744629589999093bc86e2484214f58e61df08902c", size = 239882, upload-time = "2026-06-26T18:23:27.518Z" }, + { url = "https://files.pythonhosted.org/packages/71/fd/6fea0e3d6600f785069481ee637e09378dd4118acdfd38ad88ae2db31c98/greenlet-3.5.3-cp312-cp312-win_arm64.whl", hash = "sha256:c4e7b79d83805475f0102008843f6eb45fd3bb0b2e88c774adab5fbaab27117d", size = 238211, upload-time = "2026-06-26T18:22:37.671Z" }, ] [[package]] @@ -195,6 +243,37 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/32/a5/4203481b6d3a3bb348c82ac71abf1fcb4cb3ae8422a24a8dee4cd3ac5bd7/librt-0.12.0-cp312-cp312-win_arm64.whl", hash = "sha256:5eebd451f5def089369ba6d8ff0291303d035e8154f9f26f7633835c5b029ade", size = 105117, upload-time = "2026-06-30T16:13:03.952Z" }, ] +[[package]] +name = "mako" +version = "1.3.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/62/791b31e69ae182791ec67f04850f2f062716bbd205483d63a215f3e062d3/mako-1.3.12.tar.gz", hash = "sha256:9f778e93289bd410bb35daadeb4fc66d95a746f0b75777b942088b7fd7af550a", size = 400219, upload-time = "2026-04-28T19:01:08.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/b1/a0ec7a5a9db730a08daef1fdfb8090435b82465abbf758a596f0ea88727e/mako-1.3.12-py3-none-any.whl", hash = "sha256:8f61569480282dbf557145ce441e4ba888be453c30989f879f0d652e39f53ea9", size = 78521, upload-time = "2026-04-28T19:01:10.393Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, +] + [[package]] name = "mypy" version = "2.1.0" @@ -412,6 +491,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d7/2b/9555445e1201d92b3195f45cdb153a0b68f24e0a4273f6e3d5ab46e212bb/ruff-0.15.20-py3-none-win_arm64.whl", hash = "sha256:2f5b2a6d614e8700388806a14996c40fab2c47b819ef57d790a34878858ed9ca", size = 11343498, upload-time = "2026-06-25T17:20:35.03Z" }, ] +[[package]] +name = "sqlalchemy" +version = "2.0.48" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/73/b4a9737255583b5fa858e0bb8e116eb94b88c910164ed2ed719147bde3de/sqlalchemy-2.0.48.tar.gz", hash = "sha256:5ca74f37f3369b45e1f6b7b06afb182af1fd5dde009e4ffd831830d98cbe5fe7", size = 9886075, upload-time = "2026-03-02T15:28:51.474Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/91/a42ae716f8925e9659df2da21ba941f158686856107a61cc97a95e7647a3/sqlalchemy-2.0.48-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:348174f228b99f33ca1f773e85510e08927620caa59ffe7803b37170df30332b", size = 2155737, upload-time = "2026-03-02T15:49:13.207Z" }, + { url = "https://files.pythonhosted.org/packages/b9/52/f75f516a1f3888f027c1cfb5d22d4376f4b46236f2e8669dcb0cddc60275/sqlalchemy-2.0.48-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53667b5f668991e279d21f94ccfa6e45b4e3f4500e7591ae59a8012d0f010dcb", size = 3337020, upload-time = "2026-03-02T15:50:34.547Z" }, + { url = "https://files.pythonhosted.org/packages/37/9a/0c28b6371e0cdcb14f8f1930778cb3123acfcbd2c95bb9cf6b4a2ba0cce3/sqlalchemy-2.0.48-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34634e196f620c7a61d18d5cf7dc841ca6daa7961aed75d532b7e58b309ac894", size = 3349983, upload-time = "2026-03-02T15:53:25.542Z" }, + { url = "https://files.pythonhosted.org/packages/1c/46/0aee8f3ff20b1dcbceb46ca2d87fcc3d48b407925a383ff668218509d132/sqlalchemy-2.0.48-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:546572a1793cc35857a2ffa1fe0e58571af1779bcc1ffa7c9fb0839885ed69a9", size = 3279690, upload-time = "2026-03-02T15:50:36.277Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/a957bc91293b49181350bfd55e6dfc6e30b7f7d83dc6792d72043274a390/sqlalchemy-2.0.48-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:07edba08061bc277bfdc772dd2a1a43978f5a45994dd3ede26391b405c15221e", size = 3314738, upload-time = "2026-03-02T15:53:27.519Z" }, + { url = "https://files.pythonhosted.org/packages/4b/44/1d257d9f9556661e7bdc83667cc414ba210acfc110c82938cb3611eea58f/sqlalchemy-2.0.48-cp312-cp312-win32.whl", hash = "sha256:908a3fa6908716f803b86896a09a2c4dde5f5ce2bb07aacc71ffebb57986ce99", size = 2115546, upload-time = "2026-03-02T15:54:31.591Z" }, + { url = "https://files.pythonhosted.org/packages/f2/af/c3c7e1f3a2b383155a16454df62ae8c62a30dd238e42e68c24cebebbfae6/sqlalchemy-2.0.48-cp312-cp312-win_amd64.whl", hash = "sha256:68549c403f79a8e25984376480959975212a670405e3913830614432b5daa07a", size = 2142484, upload-time = "2026-03-02T15:54:34.072Z" }, + { url = "https://files.pythonhosted.org/packages/46/2c/9664130905f03db57961b8980b05cab624afd114bf2be2576628a9f22da4/sqlalchemy-2.0.48-py3-none-any.whl", hash = "sha256:a66fe406437dd65cacd96a72689a3aaaecaebbcd62d81c5ac1c0fdbeac835096", size = 1940202, upload-time = "2026-03-02T15:52:43.285Z" }, +] + +[package.optional-dependencies] +asyncio = [ + { name = "greenlet" }, +] + [[package]] name = "starlette" version = "1.3.1" @@ -430,10 +534,13 @@ name = "tars" version = "0.1.0" source = { virtual = "." } dependencies = [ + { name = "alembic" }, + { name = "asyncpg" }, { name = "fastapi" }, { name = "pydantic" }, { name = "pydantic-settings" }, { name = "python-json-logger" }, + { name = "sqlalchemy", extra = ["asyncio"] }, { name = "uvicorn", extra = ["standard"] }, ] @@ -448,10 +555,13 @@ dev = [ [package.metadata] requires-dist = [ + { name = "alembic", specifier = "==1.18.4" }, + { name = "asyncpg", specifier = "==0.31.0" }, { name = "fastapi", specifier = ">=0.128.0" }, { name = "pydantic", specifier = ">=2.11.0" }, { name = "pydantic-settings", specifier = ">=2.10.0" }, { name = "python-json-logger", specifier = ">=3.2.0" }, + { name = "sqlalchemy", extras = ["asyncio"], specifier = "==2.0.48" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.35.0" }, ] @@ -466,11 +576,11 @@ dev = [ [[package]] name = "typing-extensions" -version = "4.16.0" +version = "4.15.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] [[package]]