Skip to content

Commit a0941c1

Browse files
Merge remote-tracking branch 'origin/main' into modal
2 parents 4d48c5c + 7c79c5b commit a0941c1

9 files changed

Lines changed: 476 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
<!-- version list -->
99

10+
## v1.0.1 (2026-06-30)
11+
12+
### Bug Fixes
13+
14+
- Address review nits on invitation warning pages
15+
([`03e08c9`](https://github.com/Promptly-Technologies-LLC/fastapi-jinja2-postgres-webapp/commit/03e08c9fdbf022ece4a82b53121a0734db601108))
16+
17+
- Harden profile-form htmx swap tests against CI timing flakiness
18+
([`03e08c9`](https://github.com/Promptly-Technologies-LLC/fastapi-jinja2-postgres-webapp/commit/03e08c9fdbf022ece4a82b53121a0734db601108))
19+
20+
### Refactoring
21+
22+
- Drop overflow-x clip in favor of geometry-based regression tests
23+
([`350af44`](https://github.com/Promptly-Technologies-LLC/fastapi-jinja2-postgres-webapp/commit/350af447d7693d64d6e0ac95d0e15f9fa01e01d3))
24+
25+
1026
## v1.0.0 (2026-06-23)
1127

1228
### Chores
Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
"""
2+
Align ownership-style foreign keys with database-level delete cascades.
3+
4+
Required when upgrading from <=1.0.1 to >1.0.1. if AccountEmail,
5+
UserAvatar, or OrganizationResource tables were created without ON DELETE
6+
CASCADE. SQLModel create_all() applies this for new databases but does not
7+
alter existing constraints.
8+
9+
Usage:
10+
uv run python -m migrations.align_ownership_cascades .env
11+
uv run python -m migrations.align_ownership_cascades .env --apply
12+
"""
13+
14+
from __future__ import annotations
15+
16+
import argparse
17+
from dataclasses import dataclass
18+
19+
from dotenv import load_dotenv
20+
from sqlalchemy import text
21+
from sqlmodel import Session, create_engine
22+
23+
from utils.core.db import get_connection_url
24+
25+
26+
@dataclass(frozen=True)
27+
class ForeignKeyTarget:
28+
source_schema: str
29+
source_table: str
30+
source_column: str
31+
target_schema: str
32+
target_table: str
33+
target_column: str
34+
constraint_name: str
35+
36+
@property
37+
def label(self) -> str:
38+
return f"{self.source_schema}.{self.source_table}.{self.source_column}"
39+
40+
41+
@dataclass
42+
class MigrationStats:
43+
already_cascading: tuple[str, ...] = ()
44+
updated: tuple[str, ...] = ()
45+
skipped_missing_tables: tuple[str, ...] = ()
46+
47+
48+
TARGETS = (
49+
ForeignKeyTarget(
50+
source_schema="private",
51+
source_table="accountemail",
52+
source_column="account_id",
53+
target_schema="private",
54+
target_table="account",
55+
target_column="id",
56+
constraint_name="fk_accountemail_account_id_account",
57+
),
58+
ForeignKeyTarget(
59+
source_schema="public",
60+
source_table="useravatar",
61+
source_column="user_id",
62+
target_schema="public",
63+
target_table="user",
64+
target_column="id",
65+
constraint_name="fk_useravatar_user_id_user",
66+
),
67+
ForeignKeyTarget(
68+
source_schema="public",
69+
source_table="organizationresource",
70+
source_column="organization_id",
71+
target_schema="public",
72+
target_table="organization",
73+
target_column="id",
74+
constraint_name="fk_organizationresource_organization_id_organization",
75+
),
76+
)
77+
78+
79+
def _quote_identifier(identifier: str) -> str:
80+
return '"' + identifier.replace('"', '""') + '"'
81+
82+
83+
def _qualified_table(schema: str, table: str) -> str:
84+
return f"{_quote_identifier(schema)}.{_quote_identifier(table)}"
85+
86+
87+
def _table_exists(session: Session, schema: str, table: str) -> bool:
88+
result = session.connection().execute(
89+
text(
90+
"""
91+
SELECT 1
92+
FROM information_schema.tables
93+
WHERE table_schema = :schema
94+
AND table_name = :table
95+
"""
96+
),
97+
{"schema": schema, "table": table},
98+
)
99+
return result.first() is not None
100+
101+
102+
def _matching_foreign_keys(
103+
session: Session, target: ForeignKeyTarget
104+
) -> list[tuple[str, str]]:
105+
result = session.connection().execute(
106+
text(
107+
"""
108+
SELECT tc.constraint_name, rc.delete_rule
109+
FROM information_schema.table_constraints AS tc
110+
JOIN information_schema.key_column_usage AS kcu
111+
ON kcu.constraint_schema = tc.constraint_schema
112+
AND kcu.constraint_name = tc.constraint_name
113+
JOIN information_schema.constraint_column_usage AS ccu
114+
ON ccu.constraint_schema = tc.constraint_schema
115+
AND ccu.constraint_name = tc.constraint_name
116+
JOIN information_schema.referential_constraints AS rc
117+
ON rc.constraint_schema = tc.constraint_schema
118+
AND rc.constraint_name = tc.constraint_name
119+
WHERE tc.constraint_type = 'FOREIGN KEY'
120+
AND tc.table_schema = :source_schema
121+
AND tc.table_name = :source_table
122+
AND kcu.column_name = :source_column
123+
AND ccu.table_schema = :target_schema
124+
AND ccu.table_name = :target_table
125+
AND ccu.column_name = :target_column
126+
ORDER BY tc.constraint_name
127+
"""
128+
),
129+
{
130+
"source_schema": target.source_schema,
131+
"source_table": target.source_table,
132+
"source_column": target.source_column,
133+
"target_schema": target.target_schema,
134+
"target_table": target.target_table,
135+
"target_column": target.target_column,
136+
},
137+
)
138+
return [(row.constraint_name, row.delete_rule) for row in result]
139+
140+
141+
def _replace_foreign_key_with_cascade(
142+
session: Session, target: ForeignKeyTarget, existing_names: list[str]
143+
) -> None:
144+
source_table = _qualified_table(target.source_schema, target.source_table)
145+
target_table = _qualified_table(target.target_schema, target.target_table)
146+
147+
for constraint_name in existing_names:
148+
session.connection().execute(
149+
text(
150+
f"ALTER TABLE {source_table} "
151+
f"DROP CONSTRAINT {_quote_identifier(constraint_name)}"
152+
)
153+
)
154+
155+
session.connection().execute(
156+
text(
157+
f"ALTER TABLE {source_table} "
158+
f"ADD CONSTRAINT {_quote_identifier(target.constraint_name)} "
159+
f"FOREIGN KEY ({_quote_identifier(target.source_column)}) "
160+
f"REFERENCES {target_table} ({_quote_identifier(target.target_column)}) "
161+
"ON DELETE CASCADE"
162+
)
163+
)
164+
165+
166+
def align_ownership_cascades(env_file: str, apply: bool) -> MigrationStats:
167+
load_dotenv(env_file, override=True)
168+
engine = create_engine(get_connection_url())
169+
already_cascading: list[str] = []
170+
updated: list[str] = []
171+
skipped_missing_tables: list[str] = []
172+
173+
try:
174+
with Session(engine) as session:
175+
for target in TARGETS:
176+
if not _table_exists(
177+
session, target.source_schema, target.source_table
178+
) or not _table_exists(
179+
session, target.target_schema, target.target_table
180+
):
181+
skipped_missing_tables.append(target.label)
182+
continue
183+
184+
existing = _matching_foreign_keys(session, target)
185+
if len(existing) == 1 and existing[0][1] == "CASCADE":
186+
already_cascading.append(target.label)
187+
continue
188+
189+
updated.append(target.label)
190+
if apply:
191+
_replace_foreign_key_with_cascade(
192+
session, target, [name for name, _ in existing]
193+
)
194+
195+
if apply:
196+
session.commit()
197+
else:
198+
session.rollback()
199+
finally:
200+
engine.dispose()
201+
202+
return MigrationStats(
203+
already_cascading=tuple(already_cascading),
204+
updated=tuple(updated),
205+
skipped_missing_tables=tuple(skipped_missing_tables),
206+
)
207+
208+
209+
def main() -> None:
210+
parser = argparse.ArgumentParser(
211+
description=(
212+
"Recreate ownership-style foreign keys with ON DELETE CASCADE. "
213+
"Without --apply, runs in dry-run mode."
214+
)
215+
)
216+
parser.add_argument("env", help="Env file to use (e.g. .env)")
217+
parser.add_argument(
218+
"--apply",
219+
action="store_true",
220+
help="Apply the schema changes (default is dry-run).",
221+
)
222+
args = parser.parse_args()
223+
224+
stats = align_ownership_cascades(env_file=args.env, apply=args.apply)
225+
mode = "APPLY" if args.apply else "DRY-RUN"
226+
print(f"[{mode}] already_cascading={list(stats.already_cascading)}")
227+
print(f"[{mode}] needs_update={list(stats.updated)}")
228+
if stats.skipped_missing_tables:
229+
print(f"[{mode}] skipped_missing_tables={list(stats.skipped_missing_tables)}")
230+
if args.apply and stats.updated:
231+
print(f"[{mode}] Foreign keys updated successfully.")
232+
elif not args.apply and stats.updated:
233+
print("Dry-run only. Re-run with --apply to recreate these foreign keys.")
234+
235+
236+
if __name__ == "__main__":
237+
main()

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "fastapi-jinja2-postgres-webapp"
3-
version = "1.0.0"
3+
version = "1.0.1"
44
description = "A template webapp with a pure-Python FastAPI backend, frontend templating with Jinja2, and a Postgres database to power user auth"
55
readme = "README.md"
66
package-mode = false

routers/core/account.py

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,16 @@
88
from fastapi.templating import Jinja2Templates
99
from starlette.datastructures import URLPath
1010
from pydantic import EmailStr
11-
from sqlmodel import Session, select
11+
from sqlmodel import Session, col, select
1212
from utils.core.models import (
1313
User,
1414
DataIntegrityError,
1515
Account,
1616
AccountEmail,
1717
Invitation,
18+
Organization,
19+
Role,
20+
UserRoleLink,
1821
)
1922
from utils.core.dependencies import get_session
2023
from utils.core.models import RefreshToken
@@ -90,6 +93,37 @@
9093
# --- Route-specific dependencies ---
9194

9295

96+
def _delete_organizations_where_user_is_only_member(
97+
session: Session, user: User
98+
) -> None:
99+
"""Delete organizations that would have no remaining users after user deletion."""
100+
if user.id is None:
101+
return
102+
103+
organization_ids = session.exec(
104+
select(Role.organization_id)
105+
.join(UserRoleLink, col(UserRoleLink.role_id) == col(Role.id))
106+
.where(UserRoleLink.user_id == user.id)
107+
.distinct()
108+
).all()
109+
110+
for organization_id in organization_ids:
111+
user_ids = {
112+
user_id
113+
for user_id in session.exec(
114+
select(UserRoleLink.user_id)
115+
.join(Role, col(Role.id) == col(UserRoleLink.role_id))
116+
.where(Role.organization_id == organization_id)
117+
.distinct()
118+
).all()
119+
if user_id is not None
120+
}
121+
if user_ids == {user.id}:
122+
organization = session.get(Organization, organization_id)
123+
if organization is not None:
124+
session.delete(organization)
125+
126+
93127
def validate_password_strength_and_match(
94128
password: str = Form(..., title="Password", description="Account password"),
95129
confirm_password: str = Form(
@@ -264,8 +298,14 @@ async def delete_account(
264298
"""
265299
Delete a user account after verifying credentials.
266300
"""
267-
# Delete the account and associated user
268-
# Note: The user will be deleted automatically by cascade relationship
301+
user = account.user
302+
if user is None:
303+
session.refresh(account, attribute_names=["user"])
304+
user = account.user
305+
306+
if user is not None:
307+
_delete_organizations_where_user_is_only_member(session, user)
308+
269309
session.delete(account)
270310
session.commit()
271311

0 commit comments

Comments
 (0)