Compare commits

..

No commits in common. "main" and "add_user" have entirely different histories.

65 changed files with 3748 additions and 4001 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

@ -23,7 +23,7 @@ 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
from src.database import DbSession from src.database import db_dependency
oidc = OpenIdConnect(openIdConnectUrl=auth_settings.OIDC_CONFIG) oidc = OpenIdConnect(openIdConnectUrl=auth_settings.OIDC_CONFIG)
@ -35,7 +35,7 @@ async def get_dev_user():
async def get_current_user( async def get_current_user(
oidc_auth_string: oidc_dependency, db: DbSession oidc_auth_string: oidc_dependency, db: db_dependency
) -> dict[str, Any]: ) -> dict[str, Any]:
config_url = urlopen(auth_settings.OIDC_CONFIG) config_url = urlopen(auth_settings.OIDC_CONFIG)
config = json.loads(config_url.read()) config = json.loads(config_url.read())

View file

@ -36,10 +36,6 @@ class Config(CustomBaseSettings):
DATABASE_HOSTNAME: str = "localhost" DATABASE_HOSTNAME: str = "localhost"
DATABASE_CREDENTIALS: SecretStr = SecretStr(":") DATABASE_CREDENTIALS: SecretStr = SecretStr(":")
DATABASE_POOL_SIZE: int = 16
DATABASE_POOL_TTL: int = 60 * 20 # 20 minutes
DATABASE_POOL_PRE_PING: bool = True
LETTERMINT_API_TOKEN: SecretStr = SecretStr("") LETTERMINT_API_TOKEN: SecretStr = SecretStr("")

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,10 +1,13 @@
""" """
Database connection and session utilities Database connections and init
Exports:
- db_dependency
- Base (sqlalchemy base model)
""" """
from contextlib import contextmanager from typing import Annotated
from typing import Annotated, Generator from sqlalchemy import create_engine, StaticPool
from sqlalchemy import create_engine, StaticPool, Connection
from sqlalchemy.orm import sessionmaker, Session from sqlalchemy.orm import sessionmaker, Session
from fastapi import Depends from fastapi import Depends
@ -20,50 +23,21 @@ if global_settings.ENVIRONMENT == Environment.TESTING:
poolclass=StaticPool, poolclass=StaticPool,
) )
else: else:
engine = create_engine( engine = create_engine(SQLALCHEMY_DATABASE_URI.get_secret_value())
SQLALCHEMY_DATABASE_URI.get_secret_value(),
pool_size=global_settings.DATABASE_POOL_SIZE,
pool_recycle=global_settings.DATABASE_POOL_TTL,
pool_pre_ping=global_settings.DATABASE_POOL_PRE_PING,
)
sm = sessionmaker(autocommit=False, expire_on_commit=False, bind=engine) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
@contextmanager def get_db():
def get_db_connection() -> Generator[Connection, None, None]: db = SessionLocal()
with engine.connect() as connection:
try: try:
yield connection yield db
except Exception: except:
connection.rollback() db.rollback()
raise
def _get_db_connection() -> Generator[Connection, None, None]:
with get_db_connection() as connection:
yield connection
DbConnection = Annotated[Connection, Depends(_get_db_connection)]
@contextmanager
def get_db_session() -> Generator[Session, None, None]:
session = sm()
try:
yield session
except Exception:
session.rollback()
raise raise
finally: finally:
session.close() db.close()
def _get_db_session() -> Generator[Session, None, None]: db_dependency = Annotated[Session, Depends(get_db)]
with get_db_session() as session:
yield session
DbSession = Annotated[Session, Depends(_get_db_session)]

View file

@ -11,14 +11,16 @@ from typing import Annotated, Optional
from fastapi import Depends, Query from fastapi import Depends, Query
from src.database import DbSession from src.database import db_dependency
from src.iam.models import Group, Permission from src.iam.models import Group, Permission
from src.iam.exceptions import GroupNotFoundException, PermNotFoundException 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: db_dependency, 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)
@ -30,7 +32,7 @@ group_model_query_dependency = Annotated[Group, Depends(get_group_model_query)]
def get_group_model_body( def get_group_model_body(
db: DbSession, request_model: Optional[GroupIDMixin] = None db: db_dependency, request_model: Optional[GroupIDMixin] = None
) -> Group: ) -> Group:
group_id = getattr(request_model, "group_id", None) group_id = getattr(request_model, "group_id", None)
if group_id is None: if group_id is None:
@ -46,7 +48,7 @@ group_model_body_dependency = Annotated[Group, Depends(get_group_model_body)]
def get_perm_model_body( def get_perm_model_body(
db: DbSession, request_model: Optional[PermIDMixin] = None db: db_dependency, request_model: Optional[PermIDMixin] = None
) -> Permission: ) -> Permission:
perm_id = getattr(request_model, "permission_id", None) perm_id = getattr(request_model, "permission_id", None)
if perm_id is None: if perm_id is None:
@ -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: db_dependency, 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

@ -32,7 +32,7 @@ from src.exceptions import (
ForbiddenException, ForbiddenException,
UnprocessableContentException, UnprocessableContentException,
) )
from src.database import DbSession from src.database import db_dependency
from src.auth.service import claims_dependency from src.auth.service import claims_dependency
from src.auth.dependencies import ( from src.auth.dependencies import (
org_model_root_claim_query_dependency, org_model_root_claim_query_dependency,
@ -107,7 +107,7 @@ router = APIRouter(
) )
async def can_act_on_resource( async def can_act_on_resource(
valid_key: service_key_dependency, valid_key: service_key_dependency,
db: DbSession, db: db_dependency,
user_claims: claims_dependency, user_claims: claims_dependency,
request_model: IAMCAoRRequest, request_model: IAMCAoRRequest,
): ):
@ -270,7 +270,7 @@ async def get_group_users(
}, },
) )
async def create_group( async def create_group(
db: DbSession, db: db_dependency,
org_model: org_model_root_claim_body_dependency, org_model: org_model_root_claim_body_dependency,
request_model: IAMPostGroupRequest, request_model: IAMPostGroupRequest,
): ):
@ -310,7 +310,7 @@ async def create_group(
}, },
) )
async def add_group_permission( async def add_group_permission(
db: DbSession, db: db_dependency,
group_model: group_model_body_dependency, group_model: group_model_body_dependency,
perm_model: perm_model_body_dependency, perm_model: perm_model_body_dependency,
org_model: org_model_root_claim_body_dependency, org_model: org_model_root_claim_body_dependency,
@ -356,7 +356,7 @@ async def add_group_permission(
}, },
) )
async def add_group_user( async def add_group_user(
db: DbSession, db: db_dependency,
group_model: group_model_body_dependency, group_model: group_model_body_dependency,
user_model: user_model_body_dependency, user_model: user_model_body_dependency,
org_model: org_model_root_claim_body_dependency, org_model: org_model_root_claim_body_dependency,
@ -399,7 +399,7 @@ async def add_group_user(
}, },
) )
async def remove_group_permission( async def remove_group_permission(
db: DbSession, db: db_dependency,
group_model: group_model_query_dependency, group_model: group_model_query_dependency,
perm_model: perm_model_query_dependency, perm_model: perm_model_query_dependency,
org_model: org_model_root_claim_query_dependency, org_model: org_model_root_claim_query_dependency,
@ -436,7 +436,7 @@ async def remove_group_permission(
}, },
) )
async def remove_group_user( async def remove_group_user(
db: DbSession, db: db_dependency,
group_model: group_model_query_dependency, group_model: group_model_query_dependency,
user_model: user_model_query_dependency, user_model: user_model_query_dependency,
org_model: org_model_root_claim_query_dependency, org_model: org_model_root_claim_query_dependency,
@ -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: db_dependency, org_model: org_model_root_claim_query_dependency
):
""" """
Returns a full list of permissions. Returns a full list of permissions.
""" """
@ -491,7 +493,7 @@ async def get_permissions(db: DbSession, org_model: org_model_root_claim_query_d
}, },
) )
async def create_new_permission( async def create_new_permission(
db: DbSession, db: db_dependency,
su: super_admin_dependency, su: super_admin_dependency,
request_model: IAMPostPermissionRequest, request_model: IAMPostPermissionRequest,
service_model: service_model_body_dependency, # Used to verify service model exists service_model: service_model_body_dependency, # Used to verify service model exists
@ -527,7 +529,7 @@ async def create_new_permission(
responses={}, responses={},
) )
async def delete_permission( async def delete_permission(
db: DbSession, db: db_dependency,
su: super_admin_dependency, su: super_admin_dependency,
perm_model: perm_model_query_dependency, perm_model: perm_model_query_dependency,
): ):
@ -546,7 +548,7 @@ async def delete_permission(
responses={}, responses={},
) )
async def permissions_search( async def permissions_search(
db: DbSession, db: db_dependency,
org_model: org_model_root_claim_body_dependency, org_model: org_model_root_claim_body_dependency,
request_model: IAMGetPermissionsSearchRequest, request_model: IAMGetPermissionsSearchRequest,
): ):
@ -630,7 +632,7 @@ async def invitation(
}, },
) )
async def accept_invitation( async def accept_invitation(
db: DbSession, db: db_dependency,
user_model: user_model_claims_dependency, user_model: user_model_claims_dependency,
request_model: IAMPutGroupInvitationAcceptRequest, request_model: IAMPutGroupInvitationAcceptRequest,
): ):
@ -676,7 +678,7 @@ async def accept_invitation(
}, },
) )
async def add_org_permissions( async def add_org_permissions(
db: DbSession, db: db_dependency,
su: super_admin_dependency, su: super_admin_dependency,
org_model: org_model_body_dependency, org_model: org_model_body_dependency,
request_model: IAMPutOrgPermissionsRequest, request_model: IAMPutOrgPermissionsRequest,

View file

@ -10,7 +10,7 @@ from datetime import datetime, timedelta, timezone
from fastapi import Request, Depends from fastapi import Request, Depends
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from src.database import DbSession from src.database import db_dependency
from src.exceptions import UnauthorizedException from src.exceptions import UnauthorizedException
from src.utils import send_email, generate_jwt from src.utils import send_email, generate_jwt
from src.iam.models import Group from src.iam.models import Group
@ -23,7 +23,7 @@ from src.service.schemas import HasServiceName
def valid_service_key( def valid_service_key(
db: DbSession, request: Request, request_model: HasServiceName db: db_dependency, request: Request, request_model: HasServiceName
) -> bool: ) -> bool:
rn = request_model.rn rn = request_model.rn
api_key = request.headers.get("X-API-Key", None) api_key = request.headers.get("X-API-Key", None)
@ -90,7 +90,7 @@ async def create_group_and_assign_perms(
async def assign_default_group( async def assign_default_group(
db: DbSession, db: db_dependency,
org_model: Org, org_model: Org,
user_model: User, user_model: User,
group_name: str, group_name: str,

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

@ -10,14 +10,14 @@ from typing import Annotated, Optional
from fastapi import Depends, Query from fastapi import Depends, Query
from src.database import DbSession from src.database import db_dependency
from src.organisation.schemas import OrgIDMixin from src.organisation.schemas import OrgIDMixin
from src.organisation.models import Organisation as Org from src.organisation.models import Organisation as Org
from src.organisation.exceptions import OrgNotFoundException from src.organisation.exceptions import OrgNotFoundException
def get_org_model_query(db: DbSession, org_id: Annotated[int, Query(gt=0)]) -> Org: def get_org_model_query(db: db_dependency, org_id: Annotated[int, Query(gt=0)]) -> Org:
org_model = db.get(Org, org_id) org_model = db.get(Org, org_id)
if org_model is None: if org_model is None:
raise OrgNotFoundException(org_id) raise OrgNotFoundException(org_id)
@ -27,7 +27,7 @@ def get_org_model_query(db: DbSession, org_id: Annotated[int, Query(gt=0)]) -> O
org_model_query_dependency = Annotated[Org, Depends(get_org_model_query)] org_model_query_dependency = Annotated[Org, Depends(get_org_model_query)]
def get_org_model_body(db: DbSession, request_model: OrgIDMixin) -> Org: def get_org_model_body(db: db_dependency, request_model: OrgIDMixin) -> Org:
org_id: Optional[int] = getattr(request_model, "organisation_id", None) org_id: Optional[int] = getattr(request_model, "organisation_id", None)
if org_id is None: if org_id is None:
raise OrgNotFoundException() raise OrgNotFoundException()

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

@ -33,7 +33,7 @@ from src.exceptions import (
from src.contact.models import Contact from src.contact.models import Contact
from src.contact.schemas import ContactAddress from src.contact.schemas import ContactAddress
from src.contact.exceptions import ContactNotFoundException from src.contact.exceptions import ContactNotFoundException
from src.database import DbSession from src.database import db_dependency
from src.organisation.schemas_questionnaires import QuestionnaireQuestionsVersion0 from src.organisation.schemas_questionnaires import QuestionnaireQuestionsVersion0
from src.organisation.service import assign_defaults from src.organisation.service import assign_defaults
from src.user.dependencies import ( from src.user.dependencies import (
@ -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: db_dependency, org_model: org_model_root_claim_query_dependency
):
""" """
Returns organisation details including key member email addresses Returns organisation details including key member email addresses
""" """
@ -141,7 +143,7 @@ async def get_org_by_id(db: DbSession, org_model: org_model_root_claim_query_dep
}, },
) )
async def create_org( async def create_org(
db: DbSession, db: db_dependency,
user_model: user_model_claims_dependency, user_model: user_model_claims_dependency,
request_model: OrgPostOrgRequest, request_model: OrgPostOrgRequest,
background_tasks: BackgroundTasks, background_tasks: BackgroundTasks,
@ -215,7 +217,7 @@ async def create_org(
}, },
) )
async def update_questionnaire( async def update_questionnaire(
db: DbSession, db: db_dependency,
org_model: org_model_root_claim_body_dependency, org_model: org_model_root_claim_body_dependency,
request_model: OrgPatchQuestionnaireRequest, request_model: OrgPatchQuestionnaireRequest,
): ):
@ -279,7 +281,7 @@ async def update_questionnaire(
}, },
) )
async def update_status( async def update_status(
db: DbSession, db: db_dependency,
org_model: org_model_body_dependency, org_model: org_model_body_dependency,
su: super_admin_dependency, su: super_admin_dependency,
request_model: OrgPatchStatusRequest, request_model: OrgPatchStatusRequest,
@ -336,7 +338,7 @@ async def get_users(org_model: org_model_root_claim_query_dependency):
}, },
) )
async def add_user_to_org( async def add_user_to_org(
db: DbSession, db: db_dependency,
org_model: org_model_body_dependency, org_model: org_model_body_dependency,
user_model: user_model_body_dependency, user_model: user_model_body_dependency,
su: super_admin_dependency, su: super_admin_dependency,
@ -378,15 +380,14 @@ async def add_user_to_org(
}, },
) )
async def delete_organisation_by_id( async def delete_organisation_by_id(
db: DbSession, db: db_dependency,
org_model: org_model_query_dependency, org_model: org_model_query_dependency,
su: super_admin_dependency, su: super_admin_dependency,
): ):
""" """
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()
@ -449,7 +450,7 @@ async def delete_organisation_by_id(
}, },
) )
async def delete_preapproved_organisation_by_id( async def delete_preapproved_organisation_by_id(
db: DbSession, db: db_dependency,
org_model: org_model_root_claim_query_dependency, org_model: org_model_root_claim_query_dependency,
): ):
""" """
@ -477,7 +478,7 @@ async def delete_preapproved_organisation_by_id(
}, },
) )
async def update_root_user( async def update_root_user(
db: DbSession, db: db_dependency,
org_model: org_model_body_dependency, org_model: org_model_body_dependency,
user_model: user_model_body_dependency, user_model: user_model_body_dependency,
su: super_admin_dependency, su: super_admin_dependency,
@ -537,7 +538,7 @@ async def get_org_groups(org_model: org_model_root_claim_query_dependency):
}, },
) )
async def remove_user_from_org( async def remove_user_from_org(
db: DbSession, db: db_dependency,
org_model: org_model_root_claim_query_dependency, org_model: org_model_root_claim_query_dependency,
user_model: user_model_query_dependency, user_model: user_model_query_dependency,
): ):
@ -608,7 +609,7 @@ async def get_contact(
}, },
) )
async def update_contact( async def update_contact(
db: DbSession, db: db_dependency,
org_model: org_model_root_claim_body_dependency, org_model: org_model_root_claim_body_dependency,
request_model: OrgPatchContactRequest, request_model: OrgPatchContactRequest,
): ):

View file

@ -9,14 +9,16 @@ Exports:
from typing import Annotated from typing import Annotated
from fastapi import Depends, Query from fastapi import Depends, Query
from src.database import DbSession from src.database import db_dependency
from src.service.exceptions import ServiceNotFoundException from src.service.exceptions import ServiceNotFoundException
from src.service.models import Service 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: db_dependency, 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)
@ -27,7 +29,7 @@ async def get_service_model_query(db: DbSession, service_id: Annotated[int, Quer
service_model_query_dependency = Annotated[Service, Depends(get_service_model_query)] service_model_query_dependency = Annotated[Service, Depends(get_service_model_query)]
async def get_service_model_body(db: DbSession, request_model: ServiceIDMixin): async def get_service_model_body(db: db_dependency, request_model: ServiceIDMixin):
service_model = db.get(Service, request_model.service_id) service_model = db.get(Service, request_model.service_id)
if service_model is None: if service_model is None:
raise ServiceNotFoundException(service_id=request_model.service_id) raise ServiceNotFoundException(service_id=request_model.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

@ -13,7 +13,7 @@ from sqlalchemy.exc import IntegrityError
from psycopg.errors import UniqueViolation from psycopg.errors import UniqueViolation
from src.exceptions import ConflictException from src.exceptions import ConflictException
from src.database import DbSession from src.database import db_dependency
from src.auth.dependencies import ( from src.auth.dependencies import (
super_admin_dependency, super_admin_dependency,
org_model_root_claim_query_dependency, org_model_root_claim_query_dependency,
@ -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: db_dependency, 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.
""" """
@ -97,7 +99,7 @@ async def get_all_services(db: DbSession, org_model: org_model_root_claim_query_
}, },
) )
async def register_service( async def register_service(
db: DbSession, db: db_dependency,
su: super_admin_dependency, su: super_admin_dependency,
request_model: ServicePostServiceRequest, request_model: ServicePostServiceRequest,
): ):
@ -133,7 +135,7 @@ async def register_service(
}, },
) )
async def regenerate_api_key( async def regenerate_api_key(
db: DbSession, db: db_dependency,
su: super_admin_dependency, su: super_admin_dependency,
service_model: service_model_body_dependency, service_model: service_model_body_dependency,
request_model: ServicePatchKeyRequest, request_model: ServicePatchKeyRequest,
@ -160,7 +162,7 @@ async def regenerate_api_key(
}, },
) )
async def remove_service( async def remove_service(
db: DbSession, db: db_dependency,
service_model: service_model_query_dependency, service_model: service_model_query_dependency,
su: super_admin_dependency, su: super_admin_dependency,
): ):
@ -183,7 +185,7 @@ async def remove_service(
}, },
) )
async def service_create_new_permissions( async def service_create_new_permissions(
db: DbSession, db: db_dependency,
request_model: ServicePostPermissionsRequest, request_model: ServicePostPermissionsRequest,
valid_key: service_key_dependency, valid_key: service_key_dependency,
): ):

View file

@ -10,16 +10,15 @@ Exports:
from typing import Annotated from typing import Annotated
from fastapi import Depends, Query from fastapi import Depends, Query
from src.auth.service import claims_dependency
from src.database import DbSession
from src.schemas import UserIDMixin
from src.exceptions import ForbiddenException
from src.user.exceptions import UserNotFoundException from src.user.exceptions import UserNotFoundException
from src.user.models import User from src.user.models import User
from src.auth.service import claims_dependency
from src.database import db_dependency
from src.schemas import UserIDMixin
async def get_user_model_claims(claims: claims_dependency, db: DbSession):
async def get_user_model_claims(claims: claims_dependency, db: db_dependency):
user_id = claims.get("db_id", None) user_id = claims.get("db_id", None)
if user_id is None: if user_id is None:
raise UserNotFoundException() raise UserNotFoundException()
@ -28,16 +27,13 @@ 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
user_model_claims_dependency = Annotated[User, Depends(get_user_model_claims)] user_model_claims_dependency = Annotated[User, Depends(get_user_model_claims)]
async def get_user_model_query(db: DbSession, user_id: Annotated[int, Query(gt=0)]): async def get_user_model_query(db: db_dependency, user_id: Annotated[int, Query(gt=0)]):
user_model = db.get(User, user_id) user_model = db.get(User, user_id)
if user_model is None: if user_model is None:
raise UserNotFoundException(user_id=user_id) raise UserNotFoundException(user_id=user_id)
@ -48,7 +44,7 @@ async def get_user_model_query(db: DbSession, user_id: Annotated[int, Query(gt=0
user_model_query_dependency = Annotated[User, Depends(get_user_model_query)] user_model_query_dependency = Annotated[User, Depends(get_user_model_query)]
async def get_user_model_body(db: DbSession, request_model: UserIDMixin): async def get_user_model_body(db: db_dependency, request_model: UserIDMixin):
user_model = db.get(User, request_model.user_id) user_model = db.get(User, request_model.user_id)
if user_model is None: if user_model is None:
raise UserNotFoundException(user_id=request_model.user_id) raise UserNotFoundException(user_id=request_model.user_id)

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
@ -35,7 +33,7 @@ from src.auth.dependencies import (
org_model_root_claim_body_dependency, org_model_root_claim_body_dependency,
) )
from src.auth.service import claims_dependency from src.auth.service import claims_dependency
from src.database import DbSession from src.database import db_dependency
from src.utils import verify_email_token from src.utils import verify_email_token
router = APIRouter( router = APIRouter(
@ -106,16 +104,15 @@ 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: db_dependency,
user_model: user_model_query_dependency, user_model: user_model_query_dependency,
su: super_admin_dependency, su: super_admin_dependency,
): ):
""" """
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()
@ -189,7 +186,7 @@ async def invitation(
response_model=UserPostInvitationAcceptResponse, response_model=UserPostInvitationAcceptResponse,
) )
async def accept_invitation( async def accept_invitation(
db: DbSession, db: db_dependency,
user_model: user_model_claims_dependency, user_model: user_model_claims_dependency,
request_model: UserPostInvitationAcceptRequest, request_model: UserPostInvitationAcceptRequest,
): ):

View file

@ -5,7 +5,6 @@ Module specific business logic for user module
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

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]]