forked from sr2/cloud-api
Compare commits
34 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7dad2e920e | |||
| bee0dcd4fe | |||
| a9e059bf0a | |||
| 5b98be9787 | |||
| be46e43042 | |||
| 8ab0390977 | |||
| cc4ae42646 | |||
| 44e1d4986f | |||
| 20615f438a | |||
| a481be8352 | |||
| e7bd455b2d | |||
| 4b3ab92d2a | |||
| ee47186c5a | |||
| fab228bf8f | |||
| b2921b73b8 | |||
| 1a851859d0 | |||
| a343b76f63 | |||
| 84ba3b6bee | |||
| 40918fd8b8 | |||
| d395b01997 | |||
| 1384ee7bd6 | |||
| df8ab32cb1 | |||
| f41f76bcf8 | |||
| d07230b3b0 | |||
| 9e1d6026b5 | |||
| c28b4dc37b | |||
| 7e1ab6c6ee | |||
| 0baa50d10f | |||
| 53b42b24dd | |||
| fe8f627fa5 | |||
| c2777db2e3 | |||
| a9e539ef74 | |||
| 02ddf9a3ed | |||
| 63e7d48c07 |
65 changed files with 4004 additions and 3761 deletions
|
|
@ -34,8 +34,6 @@ 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
|
||||||
|
|
||||||
|
|
@ -54,3 +52,35 @@ 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
4
.gitignore
vendored
|
|
@ -206,5 +206,7 @@ marimo/_static/
|
||||||
marimo/_lsp/
|
marimo/_lsp/
|
||||||
__marimo__/
|
__marimo__/
|
||||||
|
|
||||||
|
|
||||||
endpoints.txt
|
endpoints.txt
|
||||||
|
|
||||||
|
# React Frontend
|
||||||
|
/frontend/
|
||||||
|
|
@ -1 +1 @@
|
||||||
3.14
|
3.12
|
||||||
|
|
|
||||||
42
Containerfile
Normal file
42
Containerfile
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
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"]
|
||||||
32
alembic/versions/2026-06-22_fix_user_activated_default.py
Normal file
32
alembic/versions/2026-06-22_fix_user_activated_default.py
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
"""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 ###
|
||||||
44
alembic/versions/2026-06-22_model_mixins.py
Normal file
44
alembic/versions/2026-06-22_model_mixins.py
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
"""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 ###
|
||||||
|
|
@ -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.136.3",
|
"fastapi>=0.138.0",
|
||||||
"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 = [
|
||||||
|
|
|
||||||
8
renovate.json
Normal file
8
renovate.json
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||||
|
"extends": [
|
||||||
|
"config:recommended"
|
||||||
|
],
|
||||||
|
"minimumReleaseAge": "7 days",
|
||||||
|
"gitAuthor": "Renovate<noreply@sr2.uk>"
|
||||||
|
}
|
||||||
|
|
@ -22,8 +22,8 @@ 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_to_db
|
from src.user.service import add_user
|
||||||
from src.database import db_dependency
|
from src.database import DbSession
|
||||||
|
|
||||||
|
|
||||||
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: db_dependency
|
oidc_auth_string: oidc_dependency, db: DbSession
|
||||||
) -> 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())
|
||||||
|
|
@ -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_to_db(db, token.claims)
|
db_id = await add_user(db, token.claims)
|
||||||
|
|
||||||
token.claims["db_id"] = db_id
|
token.claims["db_id"] = db_id
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,10 @@ 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("")
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,16 +6,17 @@ 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):
|
class Contact(CustomBase, IdMixin):
|
||||||
__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)
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,10 @@
|
||||||
"""
|
"""
|
||||||
Database connections and init
|
Database connection and session utilities
|
||||||
|
|
||||||
Exports:
|
|
||||||
- db_dependency
|
|
||||||
- Base (sqlalchemy base model)
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Annotated
|
from contextlib import contextmanager
|
||||||
from sqlalchemy import create_engine, StaticPool
|
from typing import Annotated, Generator
|
||||||
|
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
|
||||||
|
|
@ -23,21 +20,50 @@ if global_settings.ENVIRONMENT == Environment.TESTING:
|
||||||
poolclass=StaticPool,
|
poolclass=StaticPool,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
engine = create_engine(SQLALCHEMY_DATABASE_URI.get_secret_value())
|
engine = create_engine(
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
sm = sessionmaker(autocommit=False, expire_on_commit=False, bind=engine)
|
||||||
|
|
||||||
|
|
||||||
def get_db():
|
@contextmanager
|
||||||
db = SessionLocal()
|
def get_db_connection() -> Generator[Connection, None, None]:
|
||||||
|
with engine.connect() as connection:
|
||||||
try:
|
try:
|
||||||
yield db
|
yield connection
|
||||||
except:
|
except Exception:
|
||||||
db.rollback()
|
connection.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:
|
||||||
db.close()
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
db_dependency = Annotated[Session, Depends(get_db)]
|
def _get_db_session() -> Generator[Session, None, None]:
|
||||||
|
with get_db_session() as session:
|
||||||
|
yield session
|
||||||
|
|
||||||
|
|
||||||
|
DbSession = Annotated[Session, Depends(_get_db_session)]
|
||||||
|
|
|
||||||
|
|
@ -11,16 +11,14 @@ from typing import Annotated, Optional
|
||||||
|
|
||||||
from fastapi import Depends, Query
|
from fastapi import Depends, Query
|
||||||
|
|
||||||
from src.database import db_dependency
|
from src.database import DbSession
|
||||||
|
|
||||||
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(
|
def get_group_model_query(db: DbSession, group_id: Annotated[int, Query(gt=0)]) -> Group:
|
||||||
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)
|
||||||
|
|
@ -32,7 +30,7 @@ group_model_query_dependency = Annotated[Group, Depends(get_group_model_query)]
|
||||||
|
|
||||||
|
|
||||||
def get_group_model_body(
|
def get_group_model_body(
|
||||||
db: db_dependency, request_model: Optional[GroupIDMixin] = None
|
db: DbSession, 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:
|
||||||
|
|
@ -48,7 +46,7 @@ group_model_body_dependency = Annotated[Group, Depends(get_group_model_body)]
|
||||||
|
|
||||||
|
|
||||||
def get_perm_model_body(
|
def get_perm_model_body(
|
||||||
db: db_dependency, request_model: Optional[PermIDMixin] = None
|
db: DbSession, 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:
|
||||||
|
|
@ -63,9 +61,7 @@ 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(
|
def get_perm_model_query(db: DbSession, perm_id: Annotated[int, Query(gt=0)]) -> Permission:
|
||||||
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)
|
||||||
|
|
|
||||||
|
|
@ -21,13 +21,12 @@ 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
|
from src.models import CustomBase, IdMixin
|
||||||
|
|
||||||
|
|
||||||
class Permission(CustomBase):
|
class Permission(CustomBase, IdMixin):
|
||||||
__tablename__ = "permission"
|
__tablename__ = "permission"
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
|
||||||
resource: Mapped[str]
|
resource: Mapped[str]
|
||||||
action: Mapped[str]
|
action: Mapped[str]
|
||||||
|
|
||||||
|
|
@ -61,9 +60,9 @@ class Permission(CustomBase):
|
||||||
return self.service_rel.name
|
return self.service_rel.name
|
||||||
|
|
||||||
|
|
||||||
class Group(CustomBase):
|
class Group(CustomBase, IdMixin):
|
||||||
__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"))
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ from src.exceptions import (
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
UnprocessableContentException,
|
UnprocessableContentException,
|
||||||
)
|
)
|
||||||
from src.database import db_dependency
|
from src.database import DbSession
|
||||||
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: db_dependency,
|
db: DbSession,
|
||||||
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: db_dependency,
|
db: DbSession,
|
||||||
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: db_dependency,
|
db: DbSession,
|
||||||
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: db_dependency,
|
db: DbSession,
|
||||||
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: db_dependency,
|
db: DbSession,
|
||||||
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: db_dependency,
|
db: DbSession,
|
||||||
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,9 +468,7 @@ async def remove_group_user(
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
async def get_permissions(
|
async def get_permissions(db: DbSession, org_model: org_model_root_claim_query_dependency):
|
||||||
db: db_dependency, org_model: org_model_root_claim_query_dependency
|
|
||||||
):
|
|
||||||
"""
|
"""
|
||||||
Returns a full list of permissions.
|
Returns a full list of permissions.
|
||||||
"""
|
"""
|
||||||
|
|
@ -493,7 +491,7 @@ async def get_permissions(
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
async def create_new_permission(
|
async def create_new_permission(
|
||||||
db: db_dependency,
|
db: DbSession,
|
||||||
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
|
||||||
|
|
@ -529,7 +527,7 @@ async def create_new_permission(
|
||||||
responses={},
|
responses={},
|
||||||
)
|
)
|
||||||
async def delete_permission(
|
async def delete_permission(
|
||||||
db: db_dependency,
|
db: DbSession,
|
||||||
su: super_admin_dependency,
|
su: super_admin_dependency,
|
||||||
perm_model: perm_model_query_dependency,
|
perm_model: perm_model_query_dependency,
|
||||||
):
|
):
|
||||||
|
|
@ -548,7 +546,7 @@ async def delete_permission(
|
||||||
responses={},
|
responses={},
|
||||||
)
|
)
|
||||||
async def permissions_search(
|
async def permissions_search(
|
||||||
db: db_dependency,
|
db: DbSession,
|
||||||
org_model: org_model_root_claim_body_dependency,
|
org_model: org_model_root_claim_body_dependency,
|
||||||
request_model: IAMGetPermissionsSearchRequest,
|
request_model: IAMGetPermissionsSearchRequest,
|
||||||
):
|
):
|
||||||
|
|
@ -632,7 +630,7 @@ async def invitation(
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
async def accept_invitation(
|
async def accept_invitation(
|
||||||
db: db_dependency,
|
db: DbSession,
|
||||||
user_model: user_model_claims_dependency,
|
user_model: user_model_claims_dependency,
|
||||||
request_model: IAMPutGroupInvitationAcceptRequest,
|
request_model: IAMPutGroupInvitationAcceptRequest,
|
||||||
):
|
):
|
||||||
|
|
@ -678,7 +676,7 @@ async def accept_invitation(
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
async def add_org_permissions(
|
async def add_org_permissions(
|
||||||
db: db_dependency,
|
db: DbSession,
|
||||||
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,
|
||||||
|
|
|
||||||
|
|
@ -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 db_dependency
|
from src.database import DbSession
|
||||||
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: db_dependency, request: Request, request_model: HasServiceName
|
db: DbSession, 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: db_dependency,
|
db: DbSession,
|
||||||
org_model: Org,
|
org_model: Org,
|
||||||
user_model: User,
|
user_model: User,
|
||||||
group_name: str,
|
group_name: str,
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
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
|
||||||
|
|
||||||
|
|
@ -77,3 +78,6 @@ 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")
|
||||||
|
|
|
||||||
|
|
@ -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
|
from sqlalchemy import DateTime, JSON, func
|
||||||
from sqlalchemy.orm import DeclarativeBase
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||||
|
|
||||||
|
|
||||||
class CustomBase(DeclarativeBase):
|
class CustomBase(DeclarativeBase):
|
||||||
|
|
@ -14,3 +14,24 @@ 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())
|
||||||
|
|
|
||||||
|
|
@ -10,14 +10,14 @@ from typing import Annotated, Optional
|
||||||
|
|
||||||
from fastapi import Depends, Query
|
from fastapi import Depends, Query
|
||||||
|
|
||||||
from src.database import db_dependency
|
from src.database import DbSession
|
||||||
|
|
||||||
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: db_dependency, org_id: Annotated[int, Query(gt=0)]) -> Org:
|
def get_org_model_query(db: DbSession, 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: db_dependency, org_id: Annotated[int, Query(gt=0)])
|
||||||
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: db_dependency, request_model: OrgIDMixin) -> Org:
|
def get_org_model_body(db: DbSession, 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()
|
||||||
|
|
|
||||||
|
|
@ -14,18 +14,19 @@ 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
|
from src.models import CustomBase, TimestampMixin
|
||||||
|
|
||||||
|
|
||||||
class Organisation(CustomBase):
|
class Organisation(CustomBase, IdMixin, TimestampMixin, DeletedTimestampMixin):
|
||||||
__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]
|
||||||
|
|
|
||||||
|
|
@ -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 db_dependency
|
from src.database import DbSession
|
||||||
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,9 +97,7 @@ router = APIRouter(
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
async def get_org_by_id(
|
async def get_org_by_id(db: DbSession, org_model: org_model_root_claim_query_dependency):
|
||||||
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
|
||||||
"""
|
"""
|
||||||
|
|
@ -143,7 +141,7 @@ async def get_org_by_id(
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
async def create_org(
|
async def create_org(
|
||||||
db: db_dependency,
|
db: DbSession,
|
||||||
user_model: user_model_claims_dependency,
|
user_model: user_model_claims_dependency,
|
||||||
request_model: OrgPostOrgRequest,
|
request_model: OrgPostOrgRequest,
|
||||||
background_tasks: BackgroundTasks,
|
background_tasks: BackgroundTasks,
|
||||||
|
|
@ -217,7 +215,7 @@ async def create_org(
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
async def update_questionnaire(
|
async def update_questionnaire(
|
||||||
db: db_dependency,
|
db: DbSession,
|
||||||
org_model: org_model_root_claim_body_dependency,
|
org_model: org_model_root_claim_body_dependency,
|
||||||
request_model: OrgPatchQuestionnaireRequest,
|
request_model: OrgPatchQuestionnaireRequest,
|
||||||
):
|
):
|
||||||
|
|
@ -281,7 +279,7 @@ async def update_questionnaire(
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
async def update_status(
|
async def update_status(
|
||||||
db: db_dependency,
|
db: DbSession,
|
||||||
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,
|
||||||
|
|
@ -338,7 +336,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: db_dependency,
|
db: DbSession,
|
||||||
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,
|
||||||
|
|
@ -380,14 +378,15 @@ async def add_user_to_org(
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
async def delete_organisation_by_id(
|
async def delete_organisation_by_id(
|
||||||
db: db_dependency,
|
db: DbSession,
|
||||||
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.
|
||||||
"""
|
"""
|
||||||
db.delete(org_model)
|
org_model.status = "removed"
|
||||||
|
org_model.deleted_at = datetime.now(tz=timezone.utc)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -450,7 +449,7 @@ async def delete_organisation_by_id(
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
async def delete_preapproved_organisation_by_id(
|
async def delete_preapproved_organisation_by_id(
|
||||||
db: db_dependency,
|
db: DbSession,
|
||||||
org_model: org_model_root_claim_query_dependency,
|
org_model: org_model_root_claim_query_dependency,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
|
|
@ -478,7 +477,7 @@ async def delete_preapproved_organisation_by_id(
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
async def update_root_user(
|
async def update_root_user(
|
||||||
db: db_dependency,
|
db: DbSession,
|
||||||
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,
|
||||||
|
|
@ -538,7 +537,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: db_dependency,
|
db: DbSession,
|
||||||
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,
|
||||||
):
|
):
|
||||||
|
|
@ -609,7 +608,7 @@ async def get_contact(
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
async def update_contact(
|
async def update_contact(
|
||||||
db: db_dependency,
|
db: DbSession,
|
||||||
org_model: org_model_root_claim_body_dependency,
|
org_model: org_model_root_claim_body_dependency,
|
||||||
request_model: OrgPatchContactRequest,
|
request_model: OrgPatchContactRequest,
|
||||||
):
|
):
|
||||||
|
|
|
||||||
|
|
@ -9,16 +9,14 @@ Exports:
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
from fastapi import Depends, Query
|
from fastapi import Depends, Query
|
||||||
|
|
||||||
from src.database import db_dependency
|
from src.database import DbSession
|
||||||
|
|
||||||
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(
|
async def get_service_model_query(db: DbSession, service_id: Annotated[int, Query(gt=0)]):
|
||||||
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)
|
||||||
|
|
@ -29,7 +27,7 @@ async def get_service_model_query(
|
||||||
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: db_dependency, request_model: ServiceIDMixin):
|
async def get_service_model_body(db: DbSession, 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)
|
||||||
|
|
|
||||||
|
|
@ -8,13 +8,12 @@ Models:
|
||||||
|
|
||||||
from sqlalchemy.orm import relationship, mapped_column, Mapped
|
from sqlalchemy.orm import relationship, mapped_column, Mapped
|
||||||
|
|
||||||
from src.models import CustomBase
|
from src.models import CustomBase, IdMixin
|
||||||
|
|
||||||
|
|
||||||
class Service(CustomBase):
|
class Service(CustomBase, IdMixin):
|
||||||
__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]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 db_dependency
|
from src.database import DbSession
|
||||||
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,9 +76,7 @@ router = APIRouter(
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
async def get_all_services(
|
async def get_all_services(db: DbSession, org_model: org_model_root_claim_query_dependency):
|
||||||
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.
|
||||||
"""
|
"""
|
||||||
|
|
@ -99,7 +97,7 @@ async def get_all_services(
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
async def register_service(
|
async def register_service(
|
||||||
db: db_dependency,
|
db: DbSession,
|
||||||
su: super_admin_dependency,
|
su: super_admin_dependency,
|
||||||
request_model: ServicePostServiceRequest,
|
request_model: ServicePostServiceRequest,
|
||||||
):
|
):
|
||||||
|
|
@ -135,7 +133,7 @@ async def register_service(
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
async def regenerate_api_key(
|
async def regenerate_api_key(
|
||||||
db: db_dependency,
|
db: DbSession,
|
||||||
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,
|
||||||
|
|
@ -162,7 +160,7 @@ async def regenerate_api_key(
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
async def remove_service(
|
async def remove_service(
|
||||||
db: db_dependency,
|
db: DbSession,
|
||||||
service_model: service_model_query_dependency,
|
service_model: service_model_query_dependency,
|
||||||
su: super_admin_dependency,
|
su: super_admin_dependency,
|
||||||
):
|
):
|
||||||
|
|
@ -185,7 +183,7 @@ async def remove_service(
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
async def service_create_new_permissions(
|
async def service_create_new_permissions(
|
||||||
db: db_dependency,
|
db: DbSession,
|
||||||
request_model: ServicePostPermissionsRequest,
|
request_model: ServicePostPermissionsRequest,
|
||||||
valid_key: service_key_dependency,
|
valid_key: service_key_dependency,
|
||||||
):
|
):
|
||||||
|
|
|
||||||
|
|
@ -10,15 +10,16 @@ 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()
|
||||||
|
|
@ -27,13 +28,16 @@ async def get_user_model_claims(claims: claims_dependency, db: db_dependency):
|
||||||
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: db_dependency, user_id: Annotated[int, Query(gt=0)]):
|
async def get_user_model_query(db: DbSession, 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)
|
||||||
|
|
@ -44,7 +48,7 @@ async def get_user_model_query(db: db_dependency, user_id: Annotated[int, Query(
|
||||||
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: db_dependency, request_model: UserIDMixin):
|
async def get_user_model_body(db: DbSession, 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)
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@ 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
|
||||||
|
|
@ -17,10 +19,9 @@ from sqlalchemy.orm import relationship, mapped_column, Mapped
|
||||||
from src.models import CustomBase
|
from src.models import CustomBase
|
||||||
|
|
||||||
|
|
||||||
class User(CustomBase):
|
class User(CustomBase, IdMixin, ActivatedMixin, TimestampMixin, DeletedTimestampMixin):
|
||||||
__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]
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@ 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
|
||||||
|
|
@ -33,7 +35,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 db_dependency
|
from src.database import DbSession
|
||||||
from src.utils import verify_email_token
|
from src.utils import verify_email_token
|
||||||
|
|
||||||
router = APIRouter(
|
router = APIRouter(
|
||||||
|
|
@ -104,15 +106,16 @@ 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 delete_user_by_id(
|
async def soft_delete_user_by_id(
|
||||||
db: db_dependency,
|
db: DbSession,
|
||||||
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.
|
||||||
"""
|
"""
|
||||||
db.delete(user_model)
|
user_model.active = False
|
||||||
|
user_model.deleted_at = datetime.now(tz=timezone.utc)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -186,7 +189,7 @@ async def invitation(
|
||||||
response_model=UserPostInvitationAcceptResponse,
|
response_model=UserPostInvitationAcceptResponse,
|
||||||
)
|
)
|
||||||
async def accept_invitation(
|
async def accept_invitation(
|
||||||
db: db_dependency,
|
db: DbSession,
|
||||||
user_model: user_model_claims_dependency,
|
user_model: user_model_claims_dependency,
|
||||||
request_model: UserPostInvitationAcceptRequest,
|
request_model: UserPostInvitationAcceptRequest,
|
||||||
):
|
):
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,11 @@
|
||||||
"""
|
"""
|
||||||
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
|
||||||
|
|
@ -17,7 +15,7 @@ from src.user.schemas import OIDCUser
|
||||||
from src.user.models import User
|
from src.user.models import User
|
||||||
|
|
||||||
|
|
||||||
async def add_user_to_db(db: Session, user_claims: dict[str, Any]) -> int:
|
async def add_user(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"],
|
||||||
|
|
@ -26,7 +24,7 @@ async def add_user_to_db(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:
|
||||||
print(e)
|
logging.exception(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()
|
||||||
|
|
@ -37,17 +35,10 @@ async def add_user_to_db(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
|
||||||
|
|
||||||
|
|
|
||||||
19
src/utils.py
19
src/utils.py
|
|
@ -1,3 +1,5 @@
|
||||||
|
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
|
||||||
|
|
@ -39,9 +41,12 @@ 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.is_testing or settings.ENVIRONMENT == "local":
|
if settings.ENVIRONMENT == "local":
|
||||||
recipient = "ok@testing.lettermint.co"
|
recipient = "ok@testing.lettermint.co"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
@ -52,8 +57,10 @@ async def send_email(recipient: str, subject: str, body: str):
|
||||||
.text(body)
|
.text(body)
|
||||||
.send()
|
.send()
|
||||||
)
|
)
|
||||||
|
logging.info(
|
||||||
print(response.status_code)
|
"Email sent to {} with subject {} (Status: {})".format(
|
||||||
except ValidationError:
|
recipient, subject, response.status_code
|
||||||
# Error thrown if domain not approved for project
|
)
|
||||||
print("Lettermint validation error")
|
)
|
||||||
|
except ValidationError as e:
|
||||||
|
logging.exception(e)
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
|
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
|
from fastapi.routing import APIRoute, iter_route_contexts
|
||||||
from httpx import AsyncClient, ASGITransport
|
from httpx import AsyncClient, ASGITransport
|
||||||
from sqlalchemy.orm import sessionmaker
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
|
@ -14,7 +15,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
|
from src.database import engine, get_db_session
|
||||||
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)
|
||||||
|
|
@ -38,7 +39,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] = get_db_override
|
app.dependency_overrides[get_db_session] = 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)
|
||||||
|
|
@ -55,7 +56,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] = get_db_override
|
app.dependency_overrides[get_db_session] = 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"
|
||||||
|
|
@ -70,7 +71,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] = get_db_override
|
app.dependency_overrides[get_db_session] = 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)
|
||||||
|
|
@ -258,9 +259,39 @@ def generate_body_and_status(params: dict[str, str]) -> list[tuple[dict, int]]:
|
||||||
def get_testable_routes():
|
def get_testable_routes():
|
||||||
routes = []
|
routes = []
|
||||||
|
|
||||||
for route in app.routes:
|
contexts = list(iter_route_contexts(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"}:
|
||||||
|
|
@ -269,22 +300,18 @@ def get_testable_routes():
|
||||||
routes.append(
|
routes.append(
|
||||||
(
|
(
|
||||||
method,
|
method,
|
||||||
route.path,
|
route.route.path,
|
||||||
route.status_code,
|
route.route.status_code,
|
||||||
route.response_model,
|
route.route.response_model,
|
||||||
route.summary,
|
route.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[4]}\n")
|
f.write(f"- [{ep[0]}]({ep[1]}): [{ep[5]}]: {ep[4]}\n")
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from httpx import AsyncClient
|
from httpx import AsyncClient
|
||||||
from fastapi.routing import APIRoute
|
from fastapi.routing import APIRoute, iter_route_contexts
|
||||||
|
|
||||||
from .conftest import generate_query_and_status
|
from .conftest import generate_query_and_status
|
||||||
|
|
||||||
|
|
@ -180,10 +180,15 @@ 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 default_client._transport.app.routes # ty:ignore[unresolved-attribute]
|
for route in contexts
|
||||||
if isinstance(route, APIRoute) and path in route.path and method in route.methods
|
if isinstance(route.route, APIRoute)
|
||||||
|
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
11
uv.lock
generated
|
|
@ -6,6 +6,9 @@ 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"
|
||||||
|
|
@ -238,7 +241,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.136.3" },
|
{ name = "fastapi", specifier = ">=0.138.0" },
|
||||||
{ 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" },
|
||||||
|
|
@ -349,7 +352,7 @@ wheels = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fastapi"
|
name = "fastapi"
|
||||||
version = "0.136.3"
|
version = "0.138.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "annotated-doc" },
|
{ name = "annotated-doc" },
|
||||||
|
|
@ -358,9 +361,9 @@ dependencies = [
|
||||||
{ name = "typing-extensions" },
|
{ name = "typing-extensions" },
|
||||||
{ name = "typing-inspection" },
|
{ name = "typing-inspection" },
|
||||||
]
|
]
|
||||||
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" }
|
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" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ 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" },
|
{ 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" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue