Skip to content

[SPD-48239]: Adds the initial PostgreSQL layer for consent microservice - #2

Open
AdiDev0 wants to merge 9 commits into
masterfrom
spd-48239-setup-postgres-db-models
Open

[SPD-48239]: Adds the initial PostgreSQL layer for consent microservice#2
AdiDev0 wants to merge 9 commits into
masterfrom
spd-48239-setup-postgres-db-models

Conversation

@AdiDev0

@AdiDev0 AdiDev0 commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator

User description

Summary

Adds the initial PostgreSQL ORM layer for the tars microservice, mapping the existing Django clickwraps app models to SQLAlchemy 2.x. This commit establishes the database infrastructure, enums, and all 10 control-plane ORM models needed to support the clickwrap, agreement, and legal hub domains.


Changes

Dependencies (pyproject.toml)

  • Added SQLAlchemy[asyncio]==2.0.48, asyncpg==0.31.0, alembic==1.18.4

Config (app/core/config.py, .env)

  • Added DATABASE_URL (asyncpg DSN) and CLUSTER_ID to Settings
  • .env updated with local defaults for both fields

Database infrastructure (app/db/postgres.py)

  • Base — SQLAlchemy DeclarativeBase shared by all ORM models
  • RuntimeBaseModel — abstract base for all control-plane tables; provides id (BigInteger PK), workspace_id, created_by_org_user_id, updated_by_org_user_id, created_at, updated_at (both with server_default=now())
  • SoftDeleteMixin — provides is_deleted, deleted_at, deleted_by_org_user_id; composed onto entities that support soft delete
  • Async engine (create_async_engine) and AsyncSessionLocal session factory wired to DATABASE_URL

Enums (app/db/enums.py)

  • AgreementUiType — maps Django's ClickwrapType (SINGLE_CHECKBOX, MULTIPLE_CHECKBOX, INLINE)
  • DomainStatusType — maps ClickwrapDomainStatusType (DRAFT, VERIFIED)
  • AgreementVersionStatus — maps ClickwrapAgreementVersionStatusType (DRAFT, PUBLISHED, PAST_PUBLISHED)
  • AgreementVersionSource — maps ClickwrapAgreementVersionSourceType (EDIT, EDITOR, UPLOAD)

Kept in a separate file so Pydantic schemas and domain models can import enum values without pulling in SQLAlchemy.

ORM models (app/db/models.py)

10 tables mapped from Django's clickwraps/models.py:

SQLAlchemy table Django model
packet_settings ClickwrapSettings
packet Clickwrap
domain_setting ClickwrapDomainSetting
whitelabel_config ClickwrapAgreementWhitelabelConfig
agreement ClickwrapAgreement
packet_agreement_mapping ClickwrapAgreementMapping
agreement_version ClickwrapAgreementVersion
legal_hub ClickwrapLegalHub
legal_hub_agreement_mapping ClickwrapLegalHubAgreementMapping
legal_hub_custom_url_mapping ClickwrapLegalHubAgreementCustomURLMapping

Not migrated (data moves to Firestore): ClickwrapConsent, ClickwrapUser, ClickwrapConsentAgreementVersionMapping

Key decisions:

  • All bounded Django CharField/SlugField/FileField (max_length) fields use String(N)VARCHAR(N); unbounded TextField and GCS path columns use Text
  • File fields (html_content, pdf_document, company_logo, favicon_icon) are Text/String columns storing GCS object paths — same as what Django's FileField persists in the DB; upload logic moves to the use-case/Pydantic layer
  • FK columns that reference tables in this file use DB-level ForeignKey constraints; cross-service references (workspace_id, org_user_id, etc.) are raw BigInteger columns enforced at the application layer
  • contract_type FK removed from packet per design decision
  • All existing Django constraints and indexes are preserved; no new workspace_id-leading composite indexes added
  • packet.is_default renamed from Django's default (reserved Python/SQL word); same for legal_hub_agreement_mapping.display_order from order
  • DPDPA-scoped fields excluded

Alembic (alembic.ini, alembic/env.py, alembic/script.py.mako)

  • Async-aware env.py — reads DATABASE_URL from Settings; no URL hardcoded in alembic.ini
  • compare_type=True and compare_server_default=True enabled for accurate autogeneration
  • alembic/versions/ directory created (empty; first migration generated separately)

What is NOT in this PR

  • No Alembic migration file — first migration (alembic revision --autogenerate) to be run and reviewed locally before merging
  • No DB repository code
  • No Firestore models
  • No use cases or API layer

Known manual step in first migration

Django's lh_name_unique_per_workspace constraint uses Lower(F("name")) (case-insensitive). Alembic autogenerate will produce a plain UniqueConstraint; the generated migration file must be manually edited to replace it with a functional unique index:

CREATE UNIQUE INDEX legal_hub_name_unique_per_workspace
    ON legal_hub (lower(name), workspace_id)
    WHERE is_deleted = false;

---

# Generated description

Below is a concise technical summary of the changes proposed in this PR:
Build the Postgres control-plane by wiring the async SQLAlchemy engine/session factory, shared runtime mixins, enums, and 10 ORM tables that mirror the Django clickwrap models while exposing <code>DATABASE_URL</code>/<code>CLUSTER_ID</code> through <code>Settings</code>. Document the Postgres setup and Alembic workflow, add migration templates, and register the async dependencies so schema changes can be generated and applied from the repo root.
<table><tr><th>Topic</th><th>Details</th><tr><td><a href=https://baz.co/changes/SpotDraft/tars/2?tool=ast&topic=Migration+tooling>Migration tooling</a>
        </td><td>Enable async Alembic workflows by creating the migration env that reads <code>DATABASE_URL</code>/<code>Base.metadata</code>, setting the ini/template configs, seeding the versions directory (including <code>202677_afc3f2694ab1_create_initial_clickwrap_models.py</code> and <code>.gitkeep</code>), and expanding documentation/README guidance so contributors can stand up Postgres locally and run migrations consistently.<details><summary>Modified files (7)</summary><ul><li>README.md</li>
<li>alembic.ini</li>
<li>alembic/README</li>
<li>alembic/env.py</li>
<li>alembic/script.py.mako</li>
<li>alembic/versions/.gitkeep</li>
<li>alembic/versions/202677_afc3f2694ab1_create_initial_clickwrap_models.py</li></ul></details><details><summary>Latest Contributors(2)</summary><table><tr><th>User</th><th>Commit</th><th>Date</th></tr><tr><td>aditya.raj@spotdraft.com</td><td>updated readme file</td><td>July 08, 2026</td></tr>
<tr><td>bot-github@spotdraft.com</td><td>chore: initial Tars bo...</td><td>July 08, 2026</td></tr></table></details></td></tr>
<tr><td><a href=https://baz.co/changes/SpotDraft/tars/2?tool=ast&topic=ORM+control-plane>ORM control-plane</a>
        </td><td>Implement the Postgres control-plane by updating dependencies to include async SQLAlchemy/Alembic/asyncpg, wiring <code>app/core/config.Settings</code> with <code>DATABASE_URL</code>/<code>CLUSTER_ID</code>, and defining shared runtime mixins plus enums and all 10 ORM models that preserve Django constraints and soft-delete metadata for the clickwrap, agreement, and legal hub domains.<details><summary>Modified files (6)</summary><ul><li>app/core/config.py</li>
<li>app/db/enums.py</li>
<li>app/db/models.py</li>
<li>app/db/postgres.py</li>
<li>pyproject.toml</li>
<li>uv.lock</li></ul></details><details><summary>Latest Contributors(2)</summary><table><tr><th>User</th><th>Commit</th><th>Date</th></tr><tr><td>aditya.raj@spotdraft.com</td><td>resolved comments</td><td>July 09, 2026</td></tr>
<tr><td>neo@spotdraft.com</td><td>refactor: move logging...</td><td>July 02, 2026</td></tr></table></details></td></tr></table>
<sub><a href="https://baz.co/changes/SpotDraft/tars/2?tool=ast">Review this PR on Baz</a> | <a href="https://baz.co/agents/baz">Customize your next review</a></sub>

Neo and others added 4 commits July 2, 2026 10:18
- FastAPI 0.128+ with async lifespan
- Pydantic V2 + pydantic-settings for .env config
- GCP-compatible JSON logging (GcpJsonFormatter with severity field)
- /ht health check endpoint (Kubernetes liveness/readiness probe)
- UV-managed environment (PEP 621 pyproject.toml, package = false)
- ruff linting + formatting (ruff.toml)
- Dockerfile (python:3.12-slim + uv 0.10.6)
- bors.toml merge management
- pytest AsyncClient test for /ht

Co-authored-by: Aditya Raj <aditya.raj@spotdraft.com>
- Extract GcpJsonFormatter and LoggingConfig from config.py into
  a dedicated app/core/log_config.py
- config.py now only contains Settings (single responsibility)
- Update formatter dotted-path reference from app.core.config to
  app.core.log_config
- Update main.py import accordingly

Naming: log_config.py avoids shadowing the stdlib logging module

Co-authored-by: Aditya Raj <aditya.raj@spotdraft.com>
Comment thread alembic/env.py
Comment on lines +46 to +49
def run_migrations_offline() -> None:
context.configure(
url=settings.DATABASE_URL,
target_metadata=target_metadata,

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.

Comment thread app/db/models.py Outdated
Comment thread app/db/models.py Outdated
@baz-reviewer

baz-reviewer Bot commented Jul 6, 2026

Copy link
Copy Markdown

Spec Reviewer Report

✅ 1 met requirement:

1. Define PostgreSQL database models in the microservice New SQLAlchemy async engine/base plus comprehensive ORM models cover the domain tables and expose metadata for Alembic, satisfying the PostgreSQL model requirement.

Evidence:

  • app/db/models.py: PacketSettings ORM columns defined
  • app/db/models.py: AgreementVersion unique constraints and columns set
  • app/db/postgres.py: Async engine, Base, RuntimeBaseModel, SoftDelete defined
  • app/core/config.py: DATABASE_URL default points to asyncpg Postgres
  • alembic/env.py: imports Base/models so metadata includes tables


Used resources:
Hash: 3b27c79 | Ticket: [BE] Setup Postgres db models in the microservice | Checkout in Baz

To rerun the Spec Reviewer, comment "baz rerun spec review".

Comment thread app/db/models.py
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")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Shared-database rollbacks fail

DROP EXTENSION IF EXISTS pg_trgm runs on rollback even when other schemas still depend on it, so PostgreSQL's default RESTRICT behavior aborts the downgrade instead of leaving those trigram indexes intact — should we skip dropping pg_trgm here or track whether this revision created it?

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/versions/202677_afc3f2694ab1_create_initial_clickwrap_models.py around lines
245-283, in the downgrade() logic (specifically the final statement dropping `pg_trgm`
at line 283), the migration unconditionally drops a shared PostgreSQL extension.
Refactor the downgrade so it does not drop `pg_trgm` (remove that DROP EXTENSION call)
because other migrations/tables in the same DB may still rely on trigram
indexes/operator-class dependencies. If you must keep cleanup, implement an explicit
dependency check in Python by querying Postgres catalog tables to only drop when nothing
still depends on `pg_trgm`, but the simplest safe fix is to leave extension lifecycle to
a dedicated migration or to the initial install step.

Comment thread alembic/README
Comment thread app/core/config.py
Comment thread app/core/config.py
Comment thread app/core/config.py
Comment thread app/core/config.py
@AdiDev0
AdiDev0 changed the base branch from feat/initial-boilerplate to master July 8, 2026 11:03
Comment thread app/db/models.py
Comment thread app/db/models.py Outdated
Comment thread app/db/models.py
Comment thread app/db/models.py
Comment on lines +210 to +219
__table_args__ = (
Index(
"whitelabel_config_unique_per_workspace",
"workspace_id",
"is_active",
unique=True,
postgresql_where=text("is_deleted = false"),
),
Index("whitelabel_config_workspace_is_active_idx", "workspace_id", "is_active"),
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Can you double check this? This will not allow you to store multiple deleted values.
You probably need `postgresql_where=text("is_active = true AND is_deleted = false")

Can you check this and confirm?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Its a white labelling table, it will only have one row per workspace, Only flipping of is_active field will be allowed. There wont be a scenario where there are multiple deleted objects

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I don't think that's a safe assumption. If we're implementing soft deletes, there will inevitably be deleted records over time. The partial unique index should account for that by excluding deleted rows, e.g. WHERE is_active = true AND is_deleted = false. Could you please make this change?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

In that case makes sense, i'll update this @sprajosh

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Commit f269112 addressed this comment by updating the partial unique index to exclude both inactive and deleted rows: postgresql_where=text("is_active = true AND is_deleted = false"). It also removed is_active from the indexed key columns, so deleted records can coexist without violating the uniqueness constraint.

@sd-gh-bot sd-gh-bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

PR Review: SQLAlchemy ORM Layer (SPD-48239)

Solid foundational work — the table mapping, FK policy, and soft-delete design are all clean. Found one critical bug that would break the migration, plus a few medium concerns worth addressing before merge.


🔴 Critical — Migration will fail on PostgreSQL

File: app/db/models.py lines 69, 148, 305
File: alembic/versions/202677_afc3f2694ab1_create_initial_clickwrap_models.py lines 88, 44, 134

The three enum-backed string columns use the StrEnum value directly as server_default:

# packet_settings – line 69
server_default=AgreementUiType.SINGLE_CHECKBOX       # → DEFAULT SINGLE_CHECKBOX  ❌

# domain_setting – line 148
server_default=DomainStatusType.DRAFT                # → DEFAULT DRAFT             ❌

# agreement_version – line 305
server_default=AgreementVersionSource.EDITOR         # → DEFAULT EDITOR            ❌

AgreementUiType.SINGLE_CHECKBOX is a StrEnum that evaluates to the Python string "SINGLE_CHECKBOX". When SQLAlchemy receives a plain string as server_default, it wraps it in text() and emits it verbatim into the DDL — so PostgreSQL sees DEFAULT SINGLE_CHECKBOX (no quotes), which is a syntax error. The same bug propagates into the autogenerated migration file.

Fix — wrap in embedded single quotes so Postgres sees them as string literals:

# models.py
server_default="'SINGLE_CHECKBOX'",
server_default="'DRAFT'",
server_default="'EDITOR'",

And update the corresponding migration columns:

# migration
sa.Column('agreement_ui_type', ..., server_default="'SINGLE_CHECKBOX'", ...)
sa.Column('custom_domain_status', ..., server_default="'DRAFT'", ...)
sa.Column('source', ..., server_default="'EDITOR'", ...)

The default= (Python-side) values are fine as-is since they never touch DDL. Only server_default= is affected.


🟡 Medium — agreement_version_unique_version_number index column order

File: app/db/models.py line 337-344

Index(
    "agreement_version_unique_version_number",
    "version_number",       # ← leading column
    "sub_version_number",
    "agreement_id",
    unique=True,
    postgresql_where=text("is_deleted = false"),
),

The leading column is version_number, which is not selective in a multi-tenant system (many agreements can have version 1, 2, 3…). agreement_id would be far more selective as the leading column and would make the index double as a useful covering index for "all versions of an agreement" queries.

Suggestion:

Index(
    "agreement_version_unique_version_number",
    "agreement_id",         # ← put FK first
    "version_number",
    "sub_version_number",
    unique=True,
    postgresql_where=text("is_deleted = false"),
),

🟡 Medium — AgreementVersion.is_current is nullable with no uniqueness guard

File: app/db/models.py line 319

is_current: Mapped[bool | None] = mapped_column(Boolean, nullable=True)

This is a nullable boolean without a partial unique index, so the DB cannot enforce "at most one current version per agreement." If this flag is meaningful (which it appears to be), consider:

  1. Making it non-nullable with default=False, server_default="false"
  2. Adding a partial unique index: WHERE is_current = true AND is_deleted = false on (agreement_id)

If it's purely application-managed with no DB enforcement, that's fine — just worth an explicit comment.


🟡 Medium — GCS path columns capped at String(1000)

File: app/db/models.py lines 310, 313

html_content: Mapped[str | None] = mapped_column(String(1000), ...)
pdf_document: Mapped[str | None] = mapped_column(String(1000), ...)

These store GCS object paths. GCS paths including bucket prefix, workspace ID, and a UUID-based filename can occasionally be longer than 1000 chars (especially for deeply nested prefixes or long slugs). Since these are already documented as GCS paths (not structured data), using Text — like company_logo and favicon_icon on WhitelabelConfig — would be more consistent and avoids a silent truncation footgun.


✅ Things that look correct

  • FK policy: DB-level FKs only within this file; cross-service refs (workspace_id, org_user_id) as raw BigInteger — correct.
  • Soft-delete partial indexes: All uniqueness constraints correctly scope to is_deleted = false.
  • legal_hub_name_unique_per_workspace: The case-insensitive index using lower(name) is correctly implemented via raw op.execute() in the migration (Alembic can't autogenerate functional indexes — this is the right workaround).
  • pg_trgm extension: Enabled in migration before GIN indexes are created — correct ordering.
  • Table creation order in migration: All FK dependencies are respected in the create_table ordering.
  • packet.is_default rename → is_default and orderdisplay_order**: Both documented and correct.
  • Alembic env.py: Async-aware with run_sync(do_run_migrations) — correct pattern for SQLAlchemy 2.x async.
  • downgrade(): Drops indexes and tables in the correct reverse-dependency order.

TL;DR: The critical fix is the missing SQL quotes on the three server_default enum values — the migration will hard-fail on Postgres until those are corrected. The index column order and is_current nullable design are worth a discussion before merge.

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")

@sprajosh sprajosh Jul 13, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Highly recommended to leave the extension there. This might be used by some other table.
Lets not drop extensions.

Comment thread app/db/models.py
Boolean, nullable=False, default=False, server_default="false"
)

__table_args__ = (

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Lets add a unique constrain on agreement_id + is_current, we do not want multiple is_current for a particular agreement

Comment thread app/db/models.py
class LegalHubCustomUrlMapping(SoftDeleteMixin, RuntimeBaseModel):
__tablename__ = "legal_hub_custom_url_mapping"

custom_uri: Mapped[str] = mapped_column(String(100), nullable=False)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

100 sounds pretty restrictive for a uri? Lets increase this?
Or do you think is not something we need to worry?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants