Compare commits

..

1 commit

Author SHA1 Message Date
irl
2a1d28bc54 feat(db): db tuning options and consistency 2026-06-22 12:58:37 +01:00
65 changed files with 3727 additions and 3946 deletions

View file

@ -34,6 +34,8 @@ jobs:
- run: uv python install # Gets Python version from pyproject.toml - run: uv python install # Gets Python version from pyproject.toml
- run: uv sync --dev - run: uv sync --dev
- run: uv run ty check - run: uv run ty check
- run: uv run ruff format
- run: uv run pytest test
env: env:
ENVIRONMENT: testing ENVIRONMENT: testing
@ -52,35 +54,3 @@ jobs:
- run: uv run pytest test - run: uv run pytest test
env: env:
ENVIRONMENT: testing ENVIRONMENT: testing
build:
needs: [ ruff, ty, tests ]
if: ${{ always() && needs.ruff.result == 'success' && needs.ty.result == 'success' && needs.tests.result == 'success' }}
runs-on: docker
container:
image: ghcr.io/catthehacker/ubuntu:act-latest
options: -v /dind/docker.sock:/var/run/docker.sock
steps:
- name: Checkout the repo
uses: actions/checkout@v4
- name: Checkout the frontend
uses: actions/checkout@v4
with:
repository: sr2/cloud-portal.git
path: frontend
ref: main
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to the registry
uses: docker/login-action@v3
with:
registry: guardianproject.dev
username: irl
password: ${{ secrets.PACKAGE_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
with:
file: /workspace/sr2/cloud-api/Containerfile
context: /workspace/sr2/cloud-api/
push: true
tags: guardianproject.dev/${{ github.repository }}:${{ github.ref_name }}

4
.gitignore vendored
View file

@ -206,7 +206,5 @@ marimo/_static/
marimo/_lsp/ marimo/_lsp/
__marimo__/ __marimo__/
endpoints.txt
# React Frontend endpoints.txt
/frontend/

View file

@ -1 +1 @@
3.12 3.14

View file

@ -1,42 +0,0 @@
FROM node:22-slim AS react-builder
WORKDIR /app
COPY frontend/ /app/
RUN --mount=type=cache,target=/root/.npm npm ci
RUN npm run build # Outputs to /app/dist
FROM ghcr.io/astral-sh/uv:python3.12-trixie-slim AS python-builder
ENV UV_PYTHON_DOWNLOADS=0
WORKDIR /app
# Install dependencies first (layer caching)
RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=uv.lock,target=uv.lock \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
uv sync --locked --no-install-project --no-editable
# Copy project source and install the project itself
COPY ./ /app/
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked --no-editable
FROM python:3.12-slim-trixie
WORKDIR /app
COPY alembic /app/alembic
COPY alembic.ini /app
COPY src /app/src
COPY --from=python-builder /app/.venv /app/.venv
COPY --from=react-builder /app/dist /app/static
# Ensure venv is on PATH
ENV PATH="/app/.venv/bin:$PATH" \
UV_PYTHON_DOWNLOADS=0
EXPOSE 8000
CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"]

View file

@ -1,32 +0,0 @@
"""fix user activated default
Revision ID: ae433e1c3b20
Revises: 661202797ecd
Create Date: 2026-06-22 15:26:57.805129
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'ae433e1c3b20'
down_revision: Union[str, Sequence[str], None] = '661202797ecd'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column('user', 'active', server_default=sa.true())
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column('user', 'active', server_default=sa.false())
# ### end Alembic commands ###

View file

@ -1,44 +0,0 @@
"""model mixins
Revision ID: 661202797ecd
Revises: 869d48618a1c
Create Date: 2026-06-22 13:29:39.689067
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '661202797ecd'
down_revision: Union[str, Sequence[str], None] = '869d48618a1c'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('organisation', sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()))
op.add_column('organisation', sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()))
op.add_column('organisation', sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True))
op.add_column('user', sa.Column('active', sa.Boolean(), nullable=False, server_default=sa.false()))
op.add_column('user', sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()))
op.add_column('user', sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()))
op.add_column('user', sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('user', 'deleted_at')
op.drop_column('user', 'updated_at')
op.drop_column('user', 'created_at')
op.drop_column('user', 'active')
op.drop_column('organisation', 'deleted_at')
op.drop_column('organisation', 'updated_at')
op.drop_column('organisation', 'created_at')
# ### end Alembic commands ###

View file

@ -8,7 +8,7 @@ requires-python = ">=3.12"
dependencies = [ dependencies = [
"alembic>=1.18.4", "alembic>=1.18.4",
"email-validator>=2.3.0", "email-validator>=2.3.0",
"fastapi>=0.138.0", "fastapi>=0.136.3",
"httptools>=0.7.1", "httptools>=0.7.1",
"httpx>=0.28.1", "httpx>=0.28.1",
"itsdangerous>=2.2.0", "itsdangerous>=2.2.0",
@ -34,11 +34,11 @@ line-length = 92
[tool.ruff.format] [tool.ruff.format]
quote-style = "double" quote-style = "double"
indent-style = "tab"
[tool.uv] [tool.uv]
add-bounds = "major" add-bounds = "major"
exclude-newer = "P2W" exclude-newer = "P2W"
exclude-newer-package = { "fastapi" = "2026-06-22T00:00:00Z" }
[dependency-groups] [dependency-groups]
dev = [ dev = [

View file

@ -1,8 +0,0 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:recommended"
],
"minimumReleaseAge": "7 days",
"gitAuthor": "Renovate<noreply@sr2.uk>"
}

View file

@ -22,7 +22,7 @@ from src.organisation.exceptions import AwaitingApprovalException
from src.organisation.models import Organisation as Org from src.organisation.models import Organisation as Org
from src.exceptions import UnauthorizedException, ForbiddenException from src.exceptions import UnauthorizedException, ForbiddenException
from src.auth.config import auth_settings from src.auth.config import auth_settings
from src.user.service import add_user from src.user.service import add_user_to_db
from src.database import DbSession from src.database import DbSession
@ -53,7 +53,7 @@ async def get_current_user(
claims_requests.validate(token.claims) claims_requests.validate(token.claims)
except ExpiredTokenError: except ExpiredTokenError:
raise UnauthorizedException(message="Token is expired") raise UnauthorizedException(message="Token is expired")
db_id = await add_user(db, token.claims) db_id = await add_user_to_db(db, token.claims)
token.claims["db_id"] = db_id token.claims["db_id"] = db_id

View file

@ -6,17 +6,16 @@ Models:
street_address, street_address_line_2, post_office_box_number, address_locality, country_code, address_region, postal_code street_address, street_address_line_2, post_office_box_number, address_locality, country_code, address_region, postal_code
""" """
from src.models import IdMixin
from sqlalchemy import ForeignKey from sqlalchemy import ForeignKey
from sqlalchemy.orm import mapped_column, Mapped from sqlalchemy.orm import mapped_column, Mapped
from src.models import CustomBase from src.models import CustomBase
class Contact(CustomBase, IdMixin): class Contact(CustomBase):
__tablename__ = "contact" __tablename__ = "contact"
id: Mapped[int] = mapped_column(primary_key=True)
email: Mapped[str] = mapped_column(default=None, nullable=True) email: Mapped[str] = mapped_column(default=None, nullable=True)
first_name: Mapped[str] = mapped_column(default=None, nullable=True) first_name: Mapped[str] = mapped_column(default=None, nullable=True)
last_name: Mapped[str] = mapped_column(default=None, nullable=True) last_name: Mapped[str] = mapped_column(default=None, nullable=True)

View file

@ -1,7 +1,6 @@
""" """
Database connection and session utilities Database connection and session utilities
""" """
from contextlib import contextmanager from contextlib import contextmanager
from typing import Annotated, Generator from typing import Annotated, Generator
from sqlalchemy import create_engine, StaticPool, Connection from sqlalchemy import create_engine, StaticPool, Connection
@ -30,7 +29,6 @@ else:
sm = sessionmaker(autocommit=False, expire_on_commit=False, bind=engine) sm = sessionmaker(autocommit=False, expire_on_commit=False, bind=engine)
@contextmanager @contextmanager
def get_db_connection() -> Generator[Connection, None, None]: def get_db_connection() -> Generator[Connection, None, None]:
with engine.connect() as connection: with engine.connect() as connection:
@ -40,15 +38,12 @@ def get_db_connection() -> Generator[Connection, None, None]:
connection.rollback() connection.rollback()
raise raise
def _get_db_connection() -> Generator[Connection, None]:
def _get_db_connection() -> Generator[Connection, None, None]:
with get_db_connection() as connection: with get_db_connection() as connection:
yield connection yield connection
DbConnection = Annotated[Connection, Depends(_get_db_connection)] DbConnection = Annotated[Connection, Depends(_get_db_connection)]
@contextmanager @contextmanager
def get_db_session() -> Generator[Session, None, None]: def get_db_session() -> Generator[Session, None, None]:
session = sm() session = sm()
@ -61,9 +56,8 @@ def get_db_session() -> Generator[Session, None, None]:
session.close() session.close()
def _get_db_session() -> Generator[Session, None, None]: def _get_db_session() -> Generator[Session, None]:
with get_db_session() as session: with get_db_session() as session:
yield session yield session
DbSession = Annotated[Session, Depends(_get_db_session)] DbSession = Annotated[Session, Depends(_get_db_session)]

View file

@ -18,7 +18,9 @@ from src.iam.exceptions import GroupNotFoundException, PermNotFoundException
from src.iam.schemas import GroupIDMixin, PermIDMixin from src.iam.schemas import GroupIDMixin, PermIDMixin
def get_group_model_query(db: DbSession, group_id: Annotated[int, Query(gt=0)]) -> Group: def get_group_model_query(
db: DbSession, group_id: Annotated[int, Query(gt=0)]
) -> Group:
group_model = db.get(Group, group_id) group_model = db.get(Group, group_id)
if group_model is None: if group_model is None:
raise GroupNotFoundException(group_id) raise GroupNotFoundException(group_id)
@ -61,7 +63,9 @@ def get_perm_model_body(
perm_model_body_dependency = Annotated[Permission, Depends(get_perm_model_body)] perm_model_body_dependency = Annotated[Permission, Depends(get_perm_model_body)]
def get_perm_model_query(db: DbSession, perm_id: Annotated[int, Query(gt=0)]) -> Permission: def get_perm_model_query(
db: DbSession, perm_id: Annotated[int, Query(gt=0)]
) -> Permission:
perm_model = db.get(Permission, perm_id) perm_model = db.get(Permission, perm_id)
if perm_model is None: if perm_model is None:
raise PermNotFoundException(perm_id) raise PermNotFoundException(perm_id)

View file

@ -21,12 +21,13 @@ Models:
from sqlalchemy import ForeignKey, UniqueConstraint from sqlalchemy import ForeignKey, UniqueConstraint
from sqlalchemy.orm import relationship, mapped_column, Mapped from sqlalchemy.orm import relationship, mapped_column, Mapped
from src.models import CustomBase, IdMixin from src.models import CustomBase
class Permission(CustomBase, IdMixin): class Permission(CustomBase):
__tablename__ = "permission" __tablename__ = "permission"
id: Mapped[int] = mapped_column(primary_key=True)
resource: Mapped[str] resource: Mapped[str]
action: Mapped[str] action: Mapped[str]
@ -60,9 +61,9 @@ class Permission(CustomBase, IdMixin):
return self.service_rel.name return self.service_rel.name
class Group(CustomBase, IdMixin): class Group(CustomBase):
__tablename__ = "group" __tablename__ = "group"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] name: Mapped[str]
org_id: Mapped[int] = mapped_column(ForeignKey("organisation.id", ondelete="CASCADE")) org_id: Mapped[int] = mapped_column(ForeignKey("organisation.id", ondelete="CASCADE"))

View file

@ -468,7 +468,9 @@ async def remove_group_user(
}, },
}, },
) )
async def get_permissions(db: DbSession, org_model: org_model_root_claim_query_dependency): async def get_permissions(
db: DbSession, org_model: org_model_root_claim_query_dependency
):
""" """
Returns a full list of permissions. Returns a full list of permissions.
""" """

View file

@ -2,7 +2,6 @@
Application root file: Inits the FastAPI application Application root file: Inits the FastAPI application
""" """
import os.path
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from typing import AsyncGenerator from typing import AsyncGenerator
@ -78,6 +77,3 @@ if settings.DISABLE_AUTH and (settings.ENVIRONMENT == Environment.LOCAL):
app.include_router(api_router) app.include_router(api_router)
if os.path.exists("/app/static"):
app.frontend("/ui", directory="/app/static", fallback="index.html")

View file

@ -5,8 +5,8 @@ Global database models
from datetime import datetime from datetime import datetime
from typing import Any from typing import Any
from sqlalchemy import DateTime, JSON, func from sqlalchemy import DateTime, JSON
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column from sqlalchemy.orm import DeclarativeBase
class CustomBase(DeclarativeBase): class CustomBase(DeclarativeBase):
@ -14,24 +14,3 @@ class CustomBase(DeclarativeBase):
datetime: DateTime(timezone=True), datetime: DateTime(timezone=True),
dict[str, Any]: JSON, dict[str, Any]: JSON,
} }
class ActivatedMixin:
active: Mapped[bool] = mapped_column(default=True)
class DeletedTimestampMixin:
deleted_at: Mapped[datetime | None] = mapped_column(nullable=True)
class DescriptionMixin:
description: Mapped[str]
class IdMixin:
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
class TimestampMixin:
created_at: Mapped[datetime] = mapped_column(default=func.now())
updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now())

View file

@ -14,19 +14,18 @@ Models:
- OrgUsers: org_id[FK][PK], user_id[FK][PK] - OrgUsers: org_id[FK][PK], user_id[FK][PK]
""" """
from src.models import IdMixin, DeletedTimestampMixin
from typing import Any from typing import Any
from sqlalchemy import ForeignKey from sqlalchemy import ForeignKey
from sqlalchemy.orm import relationship, Mapped, mapped_column from sqlalchemy.orm import relationship, Mapped, mapped_column
from src.models import CustomBase, TimestampMixin from src.models import CustomBase
class Organisation(CustomBase, IdMixin, TimestampMixin, DeletedTimestampMixin): class Organisation(CustomBase):
__tablename__ = "organisation" __tablename__ = "organisation"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] name: Mapped[str]
status: Mapped[str] = mapped_column(default="partial") status: Mapped[str] = mapped_column(default="partial")
intake_questionnaire: Mapped[dict[str, Any] | None] intake_questionnaire: Mapped[dict[str, Any] | None]

View file

@ -97,7 +97,9 @@ router = APIRouter(
}, },
}, },
) )
async def get_org_by_id(db: DbSession, org_model: org_model_root_claim_query_dependency): async def get_org_by_id(
db: DbSession, org_model: org_model_root_claim_query_dependency
):
""" """
Returns organisation details including key member email addresses Returns organisation details including key member email addresses
""" """
@ -385,8 +387,7 @@ async def delete_organisation_by_id(
""" """
Removes an organisation from the hub. Removes an organisation from the hub.
""" """
org_model.status = "removed" db.delete(org_model)
org_model.deleted_at = datetime.now(tz=timezone.utc)
db.commit() db.commit()

View file

@ -16,7 +16,9 @@ from src.service.models import Service
from src.service.schemas import ServiceIDMixin from src.service.schemas import ServiceIDMixin
async def get_service_model_query(db: DbSession, service_id: Annotated[int, Query(gt=0)]): async def get_service_model_query(
db: DbSession, service_id: Annotated[int, Query(gt=0)]
):
service_model = db.get(Service, service_id) service_model = db.get(Service, service_id)
if service_model is None: if service_model is None:
raise ServiceNotFoundException(service_id=service_id) raise ServiceNotFoundException(service_id=service_id)

View file

@ -8,12 +8,13 @@ Models:
from sqlalchemy.orm import relationship, mapped_column, Mapped from sqlalchemy.orm import relationship, mapped_column, Mapped
from src.models import CustomBase, IdMixin from src.models import CustomBase
class Service(CustomBase, IdMixin): class Service(CustomBase):
__tablename__ = "service" __tablename__ = "service"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(unique=True) name: Mapped[str] = mapped_column(unique=True)
api_key: Mapped[str] api_key: Mapped[str]

View file

@ -76,7 +76,9 @@ router = APIRouter(
}, },
}, },
) )
async def get_all_services(db: DbSession, org_model: org_model_root_claim_query_dependency): async def get_all_services(
db: DbSession, org_model: org_model_root_claim_query_dependency
):
""" """
Returns the ID and name of all services registered to the hub. Returns the ID and name of all services registered to the hub.
""" """

View file

@ -10,13 +10,12 @@ Exports:
from typing import Annotated from typing import Annotated
from fastapi import Depends, Query from fastapi import Depends, Query
from src.user.exceptions import UserNotFoundException
from src.user.models import User
from src.auth.service import claims_dependency from src.auth.service import claims_dependency
from src.database import DbSession from src.database import DbSession
from src.schemas import UserIDMixin from src.schemas import UserIDMixin
from src.exceptions import ForbiddenException
from src.user.exceptions import UserNotFoundException
from src.user.models import User
async def get_user_model_claims(claims: claims_dependency, db: DbSession): async def get_user_model_claims(claims: claims_dependency, db: DbSession):
@ -28,9 +27,6 @@ async def get_user_model_claims(claims: claims_dependency, db: DbSession):
if user_model is None: if user_model is None:
raise UserNotFoundException(user_id=user_id) raise UserNotFoundException(user_id=user_id)
if not user_model.active:
raise ForbiddenException("User account is not active")
return user_model return user_model

View file

@ -10,8 +10,6 @@ Models:
- groups: Calc property dict of {group_rel.org_rel.name: group_rel.name} - groups: Calc property dict of {group_rel.org_rel.name: group_rel.name}
""" """
from src.models import IdMixin, ActivatedMixin, TimestampMixin, DeletedTimestampMixin
from collections import defaultdict from collections import defaultdict
from sqlalchemy.orm import relationship, mapped_column, Mapped from sqlalchemy.orm import relationship, mapped_column, Mapped
@ -19,9 +17,10 @@ from sqlalchemy.orm import relationship, mapped_column, Mapped
from src.models import CustomBase from src.models import CustomBase
class User(CustomBase, IdMixin, ActivatedMixin, TimestampMixin, DeletedTimestampMixin): class User(CustomBase):
__tablename__ = "user" __tablename__ = "user"
id: Mapped[int] = mapped_column(primary_key=True)
email: Mapped[str] email: Mapped[str]
first_name: Mapped[str] first_name: Mapped[str]
last_name: Mapped[str] last_name: Mapped[str]

View file

@ -8,8 +8,6 @@ Endpoints:
- [DELETE](/user/): [super admin]: Removes a User(id) from the hub database. - [DELETE](/user/): [super admin]: Removes a User(id) from the hub database.
""" """
from datetime import datetime, timezone
from fastapi import APIRouter, status, BackgroundTasks from fastapi import APIRouter, status, BackgroundTasks
from src.iam.models import Group from src.iam.models import Group
@ -106,7 +104,7 @@ async def get_user_by_id(
status.HTTP_404_NOT_FOUND: {"description": "User not found"}, status.HTTP_404_NOT_FOUND: {"description": "User not found"},
}, },
) )
async def soft_delete_user_by_id( async def delete_user_by_id(
db: DbSession, db: DbSession,
user_model: user_model_query_dependency, user_model: user_model_query_dependency,
su: super_admin_dependency, su: super_admin_dependency,
@ -114,8 +112,7 @@ async def soft_delete_user_by_id(
""" """
Deletes the user with the provided ID from the database. This will not remove them from OIDC, and they will be automatically readded on next login. Deletes the user with the provided ID from the database. This will not remove them from OIDC, and they will be automatically readded on next login.
""" """
user_model.active = False db.delete(user_model)
user_model.deleted_at = datetime.now(tz=timezone.utc)
db.commit() db.commit()

View file

@ -1,11 +1,13 @@
""" """
Module specific business logic for user module Module specific business logic for user module
Exports:
- add_user_to_db: Creates a User record from OIDC claims, or updates user details
""" """
from typing import Any from typing import Any
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
import logging
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from src.exceptions import UnprocessableContentException from src.exceptions import UnprocessableContentException
@ -15,7 +17,7 @@ from src.user.schemas import OIDCUser
from src.user.models import User from src.user.models import User
async def add_user(db: Session, user_claims: dict[str, Any]) -> int: async def add_user_to_db(db: Session, user_claims: dict[str, Any]) -> int:
try: try:
valid_user = OIDCUser( valid_user = OIDCUser(
first_name=user_claims["given_name"], first_name=user_claims["given_name"],
@ -24,7 +26,7 @@ async def add_user(db: Session, user_claims: dict[str, Any]) -> int:
oidc_id=user_claims["sub"], oidc_id=user_claims["sub"],
) )
except Exception as e: except Exception as e:
logging.exception(e) print(e)
raise UnprocessableContentException("Invalid or missing OIDC data") raise UnprocessableContentException("Invalid or missing OIDC data")
db_user = db.query(User).filter(User.oidc_id == valid_user.oidc_id).first() db_user = db.query(User).filter(User.oidc_id == valid_user.oidc_id).first()
@ -35,10 +37,17 @@ async def add_user(db: Session, user_claims: dict[str, Any]) -> int:
user_id = user_model.id user_id = user_model.id
db.commit() db.commit()
return user_id return user_id
else:
user_id = db_user.id user_id = db_user.id
change = False
if db_user.first_name != valid_user.first_name:
db_user.first_name = valid_user.first_name db_user.first_name = valid_user.first_name
change = True
if db_user.last_name != valid_user.last_name:
db_user.last_name = valid_user.last_name db_user.last_name = valid_user.last_name
change = True
if change:
db.add(db_user)
db.commit() db.commit()
return user_id return user_id

View file

@ -1,5 +1,3 @@
import logging
from lettermint import Lettermint, ValidationError from lettermint import Lettermint, ValidationError
from datetime import datetime, timezone from datetime import datetime, timezone
from joserfc import jwt, jwk, errors from joserfc import jwt, jwk, errors
@ -41,12 +39,9 @@ async def verify_email_token(user_model, token):
async def send_email(recipient: str, subject: str, body: str): async def send_email(recipient: str, subject: str, body: str):
if settings.ENVIRONMENT.is_testing:
return
lettermint = Lettermint(api_token=settings.LETTERMINT_API_TOKEN.get_secret_value()) lettermint = Lettermint(api_token=settings.LETTERMINT_API_TOKEN.get_secret_value())
if settings.ENVIRONMENT == "local": if settings.ENVIRONMENT.is_testing or settings.ENVIRONMENT == "local":
recipient = "ok@testing.lettermint.co" recipient = "ok@testing.lettermint.co"
try: try:
@ -57,10 +52,8 @@ async def send_email(recipient: str, subject: str, body: str):
.text(body) .text(body)
.send() .send()
) )
logging.info(
"Email sent to {} with subject {} (Status: {})".format( print(response.status_code)
recipient, subject, response.status_code except ValidationError:
) # Error thrown if domain not approved for project
) print("Lettermint validation error")
except ValidationError as e:
logging.exception(e)

View file

@ -1,9 +1,8 @@
from fastapi.dependencies.models import Dependant
import pytest import pytest
from typing import AsyncGenerator from typing import AsyncGenerator
from itertools import combinations from itertools import combinations
from fastapi.routing import APIRoute, iter_route_contexts from fastapi.routing import APIRoute
from httpx import AsyncClient, ASGITransport from httpx import AsyncClient, ASGITransport
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
@ -15,7 +14,7 @@ from src.iam.models import Group, Permission, OrgPermissions
from src.auth.service import get_current_user, get_dev_user from src.auth.service import get_current_user, get_dev_user
from src.auth.dependencies import empty_su_list, get_super_admin_list, testing_su_list from src.auth.dependencies import empty_su_list, get_super_admin_list, testing_su_list
from src.main import app # inited FastAPI app from src.main import app # inited FastAPI app
from src.database import engine, get_db_session from src.database import engine, get_db
from src.models import CustomBase from src.models import CustomBase
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
@ -39,7 +38,7 @@ async def default_client(db_session) -> AsyncGenerator[AsyncClient, None]:
def get_db_override(): def get_db_override():
return db_session return db_session
app.dependency_overrides[get_db_session] = get_db_override app.dependency_overrides[get_db] = get_db_override
app.dependency_overrides[get_current_user] = get_dev_user app.dependency_overrides[get_current_user] = get_dev_user
app.dependency_overrides[get_super_admin_list] = testing_su_list app.dependency_overrides[get_super_admin_list] = testing_su_list
transport = ASGITransport(app=app) transport = ASGITransport(app=app)
@ -56,7 +55,7 @@ async def no_user_client(db_session) -> AsyncGenerator[AsyncClient, None]:
def get_db_override(): def get_db_override():
return db_session return db_session
app.dependency_overrides[get_db_session] = get_db_override app.dependency_overrides[get_db] = get_db_override
transport = ASGITransport(app=app) transport = ASGITransport(app=app)
async with AsyncClient( async with AsyncClient(
transport=transport, base_url="http://localhost:8000/api/v1" transport=transport, base_url="http://localhost:8000/api/v1"
@ -71,7 +70,7 @@ async def no_su_client(db_session) -> AsyncGenerator[AsyncClient, None]:
def get_db_override(): def get_db_override():
return db_session return db_session
app.dependency_overrides[get_db_session] = get_db_override app.dependency_overrides[get_db] = get_db_override
app.dependency_overrides[get_current_user] = get_dev_user app.dependency_overrides[get_current_user] = get_dev_user
app.dependency_overrides[get_super_admin_list] = empty_su_list app.dependency_overrides[get_super_admin_list] = empty_su_list
transport = ASGITransport(app=app) transport = ASGITransport(app=app)
@ -259,39 +258,9 @@ def generate_body_and_status(params: dict[str, str]) -> list[tuple[dict, int]]:
def get_testable_routes(): def get_testable_routes():
routes = [] routes = []
contexts = list(iter_route_contexts(app.routes)) for route in app.routes:
if not isinstance(route, APIRoute):
for route in contexts:
if not route.methods:
continue continue
if not isinstance(route.route, APIRoute):
continue
dep_func_names = set()
unchecked = []
unchecked.append(route.route.dependant)
while unchecked:
dependant = unchecked.pop(0)
ck = dependant.cache_key[0]
if hasattr(ck, "__name__"):
dep_func_names.add(ck.__name__)
unchecked += [
dep for dep in dependant.dependencies if isinstance(dep, Dependant)
]
auth_level = None
if "get_current_user" in dep_func_names:
auth_level = "User"
if (
"org_body_root_claims" in dep_func_names
or "org_query_root_claims" in dep_func_names
):
auth_level = "Root User"
if "user_model_super_admin" in dep_func_names:
auth_level = "Super Admin"
if "valid_service_key" in dep_func_names:
auth_level = "API Key"
for method in route.methods: for method in route.methods:
if method in {"HEAD", "OPTIONS"}: if method in {"HEAD", "OPTIONS"}:
@ -300,18 +269,22 @@ def get_testable_routes():
routes.append( routes.append(
( (
method, method,
route.route.path, route.path,
route.route.status_code, route.status_code,
route.route.response_model, route.response_model,
route.route.summary, route.summary,
auth_level,
) )
) )
return routes return routes
# with open("endpoints.txt", "w") as f:
# for ep in get_testable_routes():
# f.write(f"[{ep[0]}]({ep[1]}) -> {ep[2]}: {ep[3]}\n")
#
#
### Docstring formatted output ### ### Docstring formatted output ###
with open("endpoints.txt", "w") as f: # with open("endpoints.txt", "w") as f:
for ep in get_testable_routes(): # for ep in get_testable_routes():
f.write(f"- [{ep[0]}]({ep[1]}): [{ep[5]}]: {ep[4]}\n") # f.write(f"- [{ep[0]}]({ep[1]}): []: {ep[4]}\n")

View file

@ -5,7 +5,7 @@
import pytest import pytest
from httpx import AsyncClient from httpx import AsyncClient
from fastapi.routing import APIRoute, iter_route_contexts from fastapi.routing import APIRoute
from .conftest import generate_query_and_status from .conftest import generate_query_and_status
@ -180,15 +180,10 @@ async def test_get_self_orgs_dynamic(default_client: AsyncClient):
resp = await default_client.get(path) resp = await default_client.get(path)
contexts = list(iter_route_contexts(default_client._transport.app.routes)) # ty:ignore[unresolved-attribute]
route = next( route = next(
route.route route
for route in contexts for route in default_client._transport.app.routes # ty:ignore[unresolved-attribute]
if isinstance(route.route, APIRoute) if isinstance(route, APIRoute) and path in route.path and method in route.methods
and path in route.route.path
and isinstance(route.methods, set)
and method in route.methods
) )
assert resp.status_code == route.status_code assert resp.status_code == route.status_code

11
uv.lock generated
View file

@ -6,9 +6,6 @@ requires-python = ">=3.12"
exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values.
exclude-newer-span = "P2W" exclude-newer-span = "P2W"
[options.exclude-newer-package]
fastapi = "2026-06-22T00:00:00Z"
[[package]] [[package]]
name = "alembic" name = "alembic"
version = "1.18.4" version = "1.18.4"
@ -241,7 +238,7 @@ dev = [
requires-dist = [ requires-dist = [
{ name = "alembic", specifier = ">=1.18.4" }, { name = "alembic", specifier = ">=1.18.4" },
{ name = "email-validator", specifier = ">=2.3.0" }, { name = "email-validator", specifier = ">=2.3.0" },
{ name = "fastapi", specifier = ">=0.138.0" }, { name = "fastapi", specifier = ">=0.136.3" },
{ name = "httptools", specifier = ">=0.7.1" }, { name = "httptools", specifier = ">=0.7.1" },
{ name = "httpx", specifier = ">=0.28.1" }, { name = "httpx", specifier = ">=0.28.1" },
{ name = "itsdangerous", specifier = ">=2.2.0" }, { name = "itsdangerous", specifier = ">=2.2.0" },
@ -352,7 +349,7 @@ wheels = [
[[package]] [[package]]
name = "fastapi" name = "fastapi"
version = "0.138.0" version = "0.136.3"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "annotated-doc" }, { name = "annotated-doc" },
@ -361,9 +358,9 @@ dependencies = [
{ name = "typing-extensions" }, { name = "typing-extensions" },
{ name = "typing-inspection" }, { name = "typing-inspection" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/5b/58/ff455d9fe47c60abadb34b9e05a304b1f05f5ab8000ac01565156b6f5e43/fastapi-0.138.0.tar.gz", hash = "sha256:d445a4877636ad191e7053e08c9bf98cb921a6756776848400bb773d1740c061", size = 419240, upload-time = "2026-06-20T01:18:05.259Z" } sdist = { url = "https://files.pythonhosted.org/packages/81/2d/ff8d91d7b564d464629a0fd50a4489c97fcb836ac230bf3a7269232a9b1f/fastapi-0.136.3.tar.gz", hash = "sha256:e487fae93ad408e6f47641ee4dfe389864fd7bec92e547ea8498fc13f43e83ab", size = 396410, upload-time = "2026-05-23T18:53:15.192Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/6c/ff/8496d9847a5fedae775eb49460722d3efaa80487854273e9647ae876218c/fastapi-0.138.0-py3-none-any.whl", hash = "sha256:b6f54fd1bd72c80b0f899f172c61a600f6f7af9b43d4d772a018f35624048cb0", size = 126779, upload-time = "2026-06-20T01:18:03.483Z" }, { url = "https://files.pythonhosted.org/packages/e0/82/45359b62a067409bd929ae8a56b8ed13e5a8c8a61194b3c236920999ab83/fastapi-0.136.3-py3-none-any.whl", hash = "sha256:3d2a69bdf04b7e9f3afa292c3bc7a98816bbfafa10bc9b45f3f3700d2f761620", size = 117481, upload-time = "2026-05-23T18:53:16.924Z" },
] ]
[[package]] [[package]]