Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
"""drop is_deleted column from user

Revision ID: 01155384a530
Revises: a48b0bc6e988
Create Date: 2026-04-12 15:49:47.623781

"""

from collections.abc import Sequence

import sqlalchemy as sa
from alembic import op

# revision identifiers, used by Alembic.
revision: str = "01155384a530"
down_revision: str | Sequence[str] | None = "a48b0bc6e988"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None


def upgrade() -> None:
"""Drop is_deleted column; rewrite the deletion-due index without it."""
# Partial index references is_deleted in its WHERE clause, so it must be
# dropped before the column can go, then recreated with a simpler predicate.
op.drop_index(
op.f("ix_user_deletion_due"),
table_name="user",
postgresql_where="((is_deleted = false) AND (deletion_scheduled_at IS NOT NULL))",
)
op.drop_index(op.f("ix_user_is_deleted"), table_name="user")
op.drop_column("user", "is_deleted")
op.create_index(
"ix_user_deletion_due",
"user",
["deletion_scheduled_at"],
unique=False,
postgresql_where=sa.text(
"is_active = false AND deletion_scheduled_at IS NOT NULL"
),
)


def downgrade() -> None:
"""Recreate is_deleted column and the original partial index."""
op.drop_index(op.f("ix_user_deletion_due"), table_name="user")
op.add_column(
"user",
sa.Column(
"is_deleted",
sa.BOOLEAN(),
autoincrement=False,
nullable=False,
server_default=sa.text("false"),
),
)
op.alter_column("user", "is_deleted", server_default=None)
op.create_index(op.f("ix_user_is_deleted"), "user", ["is_deleted"], unique=False)
op.create_index(
op.f("ix_user_deletion_due"),
"user",
["deletion_scheduled_at"],
unique=False,
postgresql_where="((is_deleted = false) AND (deletion_scheduled_at IS NOT NULL))",
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
"""drop deleted_at column from user

Revision ID: 0bb6cf5b4577
Revises: 01155384a530
Create Date: 2026-04-12 16:10:20.195589

"""

from collections.abc import Sequence

import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql

# revision identifiers, used by Alembic.
revision: str = "0bb6cf5b4577"
down_revision: str | Sequence[str] | None = "01155384a530"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None


def upgrade() -> None:
"""Drop unused deleted_at column.

The partial index ``ix_user_deletion_due`` is intentionally preserved —
autogenerate wants to drop it because the predicate isn't reflected on
the model, but the deletion worker depends on it for performance.
"""
op.drop_column("user", "deleted_at")


def downgrade() -> None:
"""Recreate deleted_at column."""
op.add_column(
"user",
sa.Column(
"deleted_at",
postgresql.TIMESTAMP(timezone=True),
autoincrement=False,
nullable=True,
),
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
"""drop token_blacklist table (moved to redis)

Revision ID: 2fc986d6d710
Revises: db36f1e8fa6b
Create Date: 2026-04-12 07:57:41.442318

"""

from collections.abc import Sequence

import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql

# revision identifiers, used by Alembic.
revision: str = "2fc986d6d710"
down_revision: str | Sequence[str] | None = "db36f1e8fa6b"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None


def upgrade() -> None:
"""Upgrade schema: blacklist now lives in Redis."""
op.drop_index(op.f("ix_token_blacklist_token"), table_name="token_blacklist")
op.drop_table("token_blacklist")


def downgrade() -> None:
"""Downgrade schema: recreate the blacklist table."""
op.create_table(
"token_blacklist",
sa.Column("id", sa.UUID(), autoincrement=False, nullable=False),
sa.Column("token", sa.VARCHAR(), autoincrement=False, nullable=False),
sa.Column(
"created_at",
postgresql.TIMESTAMP(timezone=True),
autoincrement=False,
nullable=False,
),
sa.PrimaryKeyConstraint("id", name=op.f("token_blacklist_pkey")),
)
op.create_index(
op.f("ix_token_blacklist_token"), "token_blacklist", ["token"], unique=True
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
"""add ondelete cascade on user_activity fk

Revision ID: a48b0bc6e988
Revises: 2fc986d6d710
Create Date: 2026-04-12 08:07:38.336523

"""

from collections.abc import Sequence

from alembic import op

# revision identifiers, used by Alembic.
revision: str = "a48b0bc6e988"
down_revision: str | Sequence[str] | None = "2fc986d6d710"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None


def upgrade() -> None:
"""Swap the user_activity FK for one with ON DELETE CASCADE.

Lets Postgres fan out the cascade on hard-delete in a single statement
instead of the ORM issuing one DELETE per activity row.
"""
op.drop_constraint(
"user_activity_user_id_fkey", "user_activity", type_="foreignkey"
)
op.create_foreign_key(
"user_activity_user_id_fkey",
"user_activity",
"user",
["user_id"],
["id"],
ondelete="CASCADE",
)


def downgrade() -> None:
"""Revert to a non-cascading FK."""
op.drop_constraint(
"user_activity_user_id_fkey", "user_activity", type_="foreignkey"
)
op.create_foreign_key(
"user_activity_user_id_fkey",
"user_activity",
"user",
["user_id"],
["id"],
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
"""add deactivation fields to user

Revision ID: db36f1e8fa6b
Revises: 004c4063ef9a
Create Date: 2026-04-12 07:55:58.928635

"""

from collections.abc import Sequence

import sqlalchemy as sa
from alembic import op

# revision identifiers, used by Alembic.
revision: str = "db36f1e8fa6b"
down_revision: str | Sequence[str] | None = "004c4063ef9a"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None


def upgrade() -> None:
"""Upgrade schema."""
op.add_column(
"user", sa.Column("deactivated_at", sa.DateTime(timezone=True), nullable=True)
)
op.add_column(
"user",
sa.Column("deletion_scheduled_at", sa.DateTime(timezone=True), nullable=True),
)
op.create_index(
"ix_user_deletion_due",
"user",
["deletion_scheduled_at"],
unique=False,
postgresql_where=sa.text(
"is_deleted = false AND deletion_scheduled_at IS NOT NULL"
),
)


def downgrade() -> None:
"""Downgrade schema."""
op.drop_index("ix_user_deletion_due", table_name="user")
op.drop_column("user", "deletion_scheduled_at")
op.drop_column("user", "deactivated_at")
84 changes: 84 additions & 0 deletions app/api/decorators.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
"""Cross-cutting route decorators.

Kept separate from ``api/deps.py`` because FastAPI ``Depends`` belong there
and mixing the two makes it harder to spot what is pure wrapper vs. DI.
"""

from __future__ import annotations

import uuid
from collections.abc import Awaitable, Callable, Mapping
from functools import wraps
from typing import ParamSpec, TypeVar

from fastapi import HTTPException, Request
from sqlalchemy.ext.asyncio import AsyncSession

from app.models.user import User
from app.schemas.user_activity import ActivityStatus, ActivityType, ResourceType
from app.services.user_activity_service import log_activity

_UNKNOWN_USER_ID = uuid.UUID(int=0)
"""Placeholder used when an unexpected failure fires before the caller is known.

Kept explicit so audit-log readers can recognise the sentinel.
"""

P = ParamSpec("P")
R = TypeVar("R")


def audit_unexpected_failure(
*,
activity_type: ActivityType,
resource_type: ResourceType,
endpoint: str,
) -> Callable[[Callable[P, Awaitable[R]]], Callable[P, Awaitable[R]]]:
"""Log unexpected failures of a route handler and re-raise the original error.

``HTTPException`` is passed through unchanged so FastAPI's own response
logic still runs. Every other exception is recorded against the caller
(or ``_UNKNOWN_USER_ID`` when we cannot yet identify them) and re-raised
so the global exception handler can convert it to a 500 with the full
traceback intact.
"""

def decorator(func: Callable[P, Awaitable[R]]) -> Callable[P, Awaitable[R]]:
@wraps(func)
async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
try:
return await func(*args, **kwargs)
except HTTPException:
raise
except Exception as exc:
session = _find_kwarg(kwargs, AsyncSession)
request = _find_kwarg(kwargs, Request)
current_user = _find_kwarg(kwargs, User)
if session is not None:
await log_activity(
session=session,
user_id=current_user.id if current_user else _UNKNOWN_USER_ID,
activity_type=activity_type,
resource_type=resource_type,
status=ActivityStatus.FAILURE,
details={"error": str(exc), "endpoint": endpoint},
request=request,
)
raise

return wrapper

return decorator


def _find_kwarg[T](kwargs: Mapping[str, object], expected_type: type[T]) -> T | None:
"""Return the first kwarg whose runtime type matches ``expected_type``.

Route signatures vary (``session``/``db``, ``current_user`` may be
``CurrentUser`` or ``CurrentActiveUser``), so look up by type instead of
name to keep the decorator generic.
"""
for value in kwargs.values():
if isinstance(value, expected_type):
return value
return None
Loading
Loading