From 7b3ee9d5fa33930eca70b771eaf85a9ed8c4d81d Mon Sep 17 00:00:00 2001 From: luxferre Date: Thu, 21 May 2026 16:55:15 +0100 Subject: [PATCH 01/10] feat: db dependency upgrade No longer using .begin() and context manager. This means commits must be explicit (already were) but also, it allows for sanity checks within routes after commits. --- src/database.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/database.py b/src/database.py index 1761560..819faeb 100644 --- a/src/database.py +++ b/src/database.py @@ -17,13 +17,16 @@ engine = create_engine(SQLALCHEMY_DATABASE_URI.get_secret_value()) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + def get_db(): - with SessionLocal.begin() as db: - try: - yield db - finally: - db.rollback() # Anything not explicitly commited is rolled back - db.close() + db = SessionLocal() + try: + yield db + except: + db.rollback() + raise + finally: + db.close() db_dependency = Annotated[Session, Depends(get_db)] From 23f2ce98d758360f6e08d56817aa53b3dc5543b7 Mon Sep 17 00:00:00 2001 From: luxferre Date: Mon, 25 May 2026 09:05:17 +0100 Subject: [PATCH 02/10] feat: iam rbac system Endpoints and db architecture to support a role based IAM system. --- .alembic/env.py | 2 + .alembic/versions/2026-05-22_init_iam.py | 83 ++++++++++ src/admin/router.py | 36 +---- src/api.py | 4 + src/auth/router.py | 52 +----- src/auth/service.py | 138 ---------------- src/iam/config.py | 7 + src/iam/constants.py | 7 + src/iam/dependencies.py | 11 ++ src/iam/exceptions.py | 7 + src/iam/models.py | 43 +++++ src/iam/router.py | 192 +++++++++++++++++++++++ src/iam/schemas.py | 7 + src/iam/service.py | 26 +++ src/iam/utils.py | 11 ++ src/organisation/dependencies.py | 19 ++- src/organisation/models.py | 7 +- src/organisation/router.py | 76 ++++----- src/organisation/schemas.py | 2 - src/schemas.py | 8 + src/service/config.py | 7 + src/service/constants.py | 7 + src/service/dependencies.py | 11 ++ src/service/exceptions.py | 7 + src/service/models.py | 18 +++ src/service/router.py | 63 ++++++++ src/service/schemas.py | 7 + src/service/service.py | 11 ++ src/service/utils.py | 16 ++ src/user/router.py | 65 ++------ src/user/schemas.py | 1 - 31 files changed, 634 insertions(+), 317 deletions(-) create mode 100644 .alembic/versions/2026-05-22_init_iam.py create mode 100644 src/iam/config.py create mode 100644 src/iam/constants.py create mode 100644 src/iam/dependencies.py create mode 100644 src/iam/exceptions.py create mode 100644 src/iam/models.py create mode 100644 src/iam/router.py create mode 100644 src/iam/schemas.py create mode 100644 src/iam/service.py create mode 100644 src/iam/utils.py create mode 100644 src/service/config.py create mode 100644 src/service/constants.py create mode 100644 src/service/dependencies.py create mode 100644 src/service/exceptions.py create mode 100644 src/service/models.py create mode 100644 src/service/router.py create mode 100644 src/service/schemas.py create mode 100644 src/service/service.py create mode 100644 src/service/utils.py diff --git a/.alembic/env.py b/.alembic/env.py index ec4697c..87dfd54 100644 --- a/.alembic/env.py +++ b/.alembic/env.py @@ -10,6 +10,8 @@ from src.config import SQLALCHEMY_DATABASE_URI from src.contact.models import Contact from src.organisation.models import Organisation, OrgUsers from src.user.models import User +from src.service.models import Service +from src.iam.models import Permission, Group, GroupPermissions, UserGroups from src.database import Base # this is the Alembic Config object, which provides diff --git a/.alembic/versions/2026-05-22_init_iam.py b/.alembic/versions/2026-05-22_init_iam.py new file mode 100644 index 0000000..3197d47 --- /dev/null +++ b/.alembic/versions/2026-05-22_init_iam.py @@ -0,0 +1,83 @@ +"""Init IAM + +Revision ID: a147965e644e +Revises: 8fe51426321d +Create Date: 2026-05-22 15:59:36.469374 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'a147965e644e' +down_revision: Union[str, Sequence[str], None] = '8fe51426321d' +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.create_table('service', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(), nullable=True), + sa.Column('api_key', sa.String(), nullable=True), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('api_key'), + sa.UniqueConstraint('name') + ) + op.create_table('permission', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('resource', sa.String(), nullable=False), + sa.Column('action', sa.String(), nullable=False), + sa.Column('service_id', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['service_id'], ['service.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('group', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(), nullable=False), + sa.Column('org_id', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['org_id'], ['organisation.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('name') + ) + op.create_table('group_permissions', + sa.Column('group_id', sa.Integer(), nullable=False), + sa.Column('permission_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['group_id'], ['group.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['permission_id'], ['permission.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('group_id', 'permission_id') + ) + op.create_table('user_groups', + sa.Column('org_id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('group_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['group_id'], ['group.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['org_id'], ['organisation.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('org_id', 'user_id', 'group_id') + ) + op.add_column('organisation', sa.Column('root_user_id', sa.Integer(), nullable=True)) + op.create_unique_constraint("organisation_name_key", 'organisation', ['name']) + op.create_foreign_key("organisation_root_user_fkey", 'organisation', 'user', ['root_user_id'], ['id']) + op.drop_column('orgusers', 'is_admin') + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('orgusers', sa.Column('is_admin', sa.BOOLEAN(), server_default=sa.text('false'), autoincrement=False, nullable=False)) + op.drop_constraint("organisation_root_user_fkey", 'organisation', type_='foreignkey') + op.drop_constraint("organisation_name_key", 'organisation', type_='unique') + op.drop_column('organisation', 'root_user_id') + op.drop_table('user_groups') + op.drop_table('group_permissions') + op.drop_table('group') + op.drop_table('permission') + op.drop_table('service') + # ### end Alembic commands ### diff --git a/src/admin/router.py b/src/admin/router.py index e6df7de..13ac897 100644 --- a/src/admin/router.py +++ b/src/admin/router.py @@ -5,43 +5,9 @@ Endpoints: - List: Description - Endpoints: Description """ -from typing import Annotated - -from fastapi import APIRouter, HTTPException -from fastapi.params import Path - -from src.organisation.constants import ContactType -from src.organisation.schemas import OrgContactGetResponse -from src.organisation.models import Organisation as Org -from src.contact.models import Contact - -from src.auth.service import claims_dependency, org_or_super_admin_dependency -from src.database import db_dependency - +from fastapi import APIRouter router = APIRouter( tags=["admin"], prefix="/admin", ) - - -@router.get("/{org_id}/contact/{contact_type}", response_model=OrgContactGetResponse) -async def get_contact(db: db_dependency, user: claims_dependency, is_admin: org_or_super_admin_dependency, contact_type: ContactType, org_id: Annotated[int, Path(gt=0)]): - org_model = db.query(Org).filter(Org.id == org_id).first() - if org_model is None: - raise HTTPException(status_code=404, detail="Organisation not found") - match contact_type: - case "billing": - contact_id = org_model.billing_contact_id - case "security": - contact_id = org_model.security_contact_id - case "owner": - contact_id = org_model.owner_contact_id - case _: - raise HTTPException(status_code=422, detail="Invalid contact type") - - contact_model = (db.query(Contact).filter(Contact.id == contact_id).first()) - if contact_model is None: - raise HTTPException(status_code=404, detail="Contact not found") - - return contact_model diff --git a/src/api.py b/src/api.py index 502ed47..bf53dd0 100644 --- a/src/api.py +++ b/src/api.py @@ -8,6 +8,8 @@ from src.contact.router import router as contact_router from src.organisation.router import router as organisation_router from src.user.router import router as user_router from src.admin.router import router as admin_router +from src.iam.router import router as iam_router +from src.service.router import router as service_router api_router = APIRouter() @@ -17,6 +19,8 @@ api_router.include_router(contact_router) api_router.include_router(organisation_router) api_router.include_router(user_router) api_router.include_router(admin_router) +api_router.include_router(service_router) +api_router.include_router(iam_router) @api_router.get("/healthcheck", include_in_schema=False) diff --git a/src/auth/router.py b/src/auth/router.py index 5a8636f..5e8871d 100644 --- a/src/auth/router.py +++ b/src/auth/router.py @@ -8,54 +8,4 @@ from fastapi import APIRouter router = APIRouter( tags=["auth"], -) - - - - -# oauth = OAuth() -# oauth.register( -# name="oidc", -# server_metadata_url=auth_settings.OIDC_CONFIG, -# client_id=auth_settings.CLIENT_ID, -# client_secret=None, -# code_challenge_method="S256", -# client_kwargs={ -# "code_challenge_method": "S256", -# "scope": "openid profile email", -# } -# ) - - -# @auth_router.get('/login') -# async def login(request: Request): -# redirect_uri = request.url_for('auth') -# return await oauth.oidc.authorize_redirect(request, redirect_uri, code_challenge_method="S256") -# -# -# @auth_router.get('/auth', include_in_schema=False) -# async def auth(db: db_dependency, request: Request): -# token = await oauth.oidc.authorize_access_token(request) -# user = token.get("userinfo") -# request.session["user"] = user -# -# try: -# valid_user = OIDCUser(first_name=user["given_name"], last_name=user["family_name"], email=user["email"], oidc_id=user["sub"]) -# except Exception as e: -# print(e) -# raise HTTPException(status_code=422, detail="Invalid or missing OIDC data") -# -# user_exists = db.query(exists().where(User.oidc_id == valid_user.oidc_id)).scalar() -# -# if not user_exists: -# user_model = User(**valid_user.model_dump()) -# db.add(user_model) -# db.commit() -# -# return RedirectResponse(url="/") -# -# -# @auth_router.get('/logout') -# async def logout(request: Request): -# request.session.pop('user', None) -# return RedirectResponse(url='/') +) \ No newline at end of file diff --git a/src/auth/service.py b/src/auth/service.py index fc3cd4c..40a696b 100644 --- a/src/auth/service.py +++ b/src/auth/service.py @@ -88,33 +88,6 @@ async def is_org_user(claims: claims_dependency, db: db_dependency, org_id: int org_user_dependency = Annotated[dict[str, Any], Depends(is_org_user)] -async def is_org_admin(claims: claims_dependency, db: db_dependency, org_id: int = Path(gt=0)): - org_exists = db.query(exists().where(Org.id == org_id)).scalar() - if not org_exists: - raise HTTPException(status_code=404, detail="Organisation not found") - - db_id = claims.get("db_id", None) - if db_id is None: - raise HTTPException(status_code=404, detail="User not found in db") - - exists_query = (db.query(OrgUsers) - .filter(OrgUsers.org_id == org_id, - OrgUsers.user_id == db_id, - OrgUsers.is_admin == True - ).exists() - ) - - org_admin_exists = db.query(exists_query).scalar() - - if not org_admin_exists: - raise HTTPException(status_code=401, detail="Not authorised") - - return org_admin_exists - - -org_admin_dependency = Annotated[dict[str, Any], Depends(is_org_admin)] - - async def is_super_admin(claims: claims_dependency): super_admin_ids = [] @@ -128,114 +101,3 @@ async def is_super_admin(claims: claims_dependency): super_admin_dependency = Annotated[dict[str, Any], Depends(is_super_admin)] - - -async def is_admin(claims: claims_dependency, db: db_dependency, org_id: int = Path(gt=0)): - try: - await is_super_admin(claims) - return True - except HTTPException as e: - pass - try: - await is_org_admin(claims, db, org_id) - return True - except HTTPException as e: - raise HTTPException(status_code=401, detail="Not authorised") - -org_or_super_admin_dependency = Annotated[dict[str, Any], Depends(is_admin)] - -# Middleware version of user auth -# import json -# import logging -# -# from threading import Timer -# from urllib.request import urlopen -# from starlette.requests import HTTPConnection, Request -# -# from authlib.jose.rfc7517.jwk import JsonWebKey -# from authlib.jose.rfc7517.key_set import KeySet -# from authlib.oauth2 import OAuth2Error, ResourceProtector -# from authlib.oauth2.rfc6749 import MissingAuthorizationError -# from authlib.oauth2.rfc7523 import JWTBearerTokenValidator -# from authlib.oauth2.rfc7523.validator import JWTBearerToken -# -# from starlette.authentication import ( -# AuthCredentials, -# AuthenticationBackend, -# AuthenticationError, -# SimpleUser, -# ) -# -# logger = logging.getLogger(__name__) -# -# -# class RepeatTimer(Timer): -# def __init__(self, *args, **kwargs) -> None: -# super().__init__(*args, **kwargs) -# self.daemon = True -# -# def run(self): -# while not self.finished.wait(self.interval): -# self.function(*self.args, **self.kwargs) -# -# -# class BearerTokenValidator(JWTBearerTokenValidator): -# def __init__(self, issuer: str, audience: str): -# self._issuer = issuer -# self._jwks_uri: str | None = None -# super().__init__(public_key=self.fetch_key(), issuer=issuer) -# self.claims_options = { -# "exp": {"essential": True}, -# "aud": {"essential": True, "value": audience}, -# "iss": {"essential": True, "value": issuer}, -# } -# self._timer = RepeatTimer(3600, self.refresh) -# self._timer.start() -# -# def refresh(self): -# try: -# self.public_key = self.fetch_key() -# except Exception as exc: -# logger.warning(f"Could not update jwks public key: {exc}") -# -# def fetch_key(self) -> KeySet: -# """Fetch the jwks_uri document and return the KeySet.""" -# response = urlopen(self.jwks_uri) -# logger.debug(f"OK GET {self.jwks_uri}") -# return JsonWebKey.import_key_set(json.loads(response.read())) -# -# @property -# def jwks_uri(self) -> str: -# """The jwks_uri field of the openid-configuration document.""" -# if self._jwks_uri is None: -# config_url = urlopen(f"{self._issuer}/.well-known/openid-configuration") -# config = json.loads(config_url.read()) -# self._jwks_uri = config["jwks_uri"] -# return self._jwks_uri -# -# -# class BearerTokenAuthBackend(AuthenticationBackend): -# def __init__(self, issuer: str, audience: str) -> None: -# rp = ResourceProtector() -# validator = BearerTokenValidator( -# issuer=issuer, -# audience=audience, -# ) -# rp.register_token_validator(validator) -# self.resource_protector = rp -# -# async def authenticate(self, conn: HTTPConnection): -# if "Authorization" not in conn.headers: -# return -# request = Request(conn.scope) -# try: -# token: JWTBearerToken = self.resource_protector.validate_request( -# scopes=["openid"], -# request=request, -# ) -# except (MissingAuthorizationError, OAuth2Error) as error: -# raise AuthenticationError(error.description) from error -# scope: str = token.get_scope() -# scopes = scope.split() -# scopes.append("authenticated") -# return AuthCredentials(scopes=scopes), SimpleUser(username=token["email"]) diff --git a/src/iam/config.py b/src/iam/config.py new file mode 100644 index 0000000..4be170e --- /dev/null +++ b/src/iam/config.py @@ -0,0 +1,7 @@ +""" +Configurations for + +Configurations: + - List: Description + - Configs: Description +""" \ No newline at end of file diff --git a/src/iam/constants.py b/src/iam/constants.py new file mode 100644 index 0000000..e1df957 --- /dev/null +++ b/src/iam/constants.py @@ -0,0 +1,7 @@ +""" +Constants and error codes for + +Constants: + - List: Description + - Consts: Description +""" \ No newline at end of file diff --git a/src/iam/dependencies.py b/src/iam/dependencies.py new file mode 100644 index 0000000..7447aaf --- /dev/null +++ b/src/iam/dependencies.py @@ -0,0 +1,11 @@ +""" +Router dependencies for + +Classes: + - List: Description + - Classes: Description + +Functions: + - List: Description + - Functions: Description +""" \ No newline at end of file diff --git a/src/iam/exceptions.py b/src/iam/exceptions.py new file mode 100644 index 0000000..5debbb4 --- /dev/null +++ b/src/iam/exceptions.py @@ -0,0 +1,7 @@ +""" +Module specific exceptions for + +Exceptions: + - List: Description + - Exceptions: Description +""" \ No newline at end of file diff --git a/src/iam/models.py b/src/iam/models.py new file mode 100644 index 0000000..5ec6065 --- /dev/null +++ b/src/iam/models.py @@ -0,0 +1,43 @@ +""" +Database models for the IAM module + +Models: + - List: Description + - Models: Description +""" +from sqlalchemy import Column, Integer, String, ForeignKey, UniqueConstraint + +from src.database import Base + + +class Permission(Base): + __tablename__ = "permission" + + id = Column(Integer, primary_key=True) + resource = Column(String, nullable=False) + action = Column(String, nullable=False) + + service_id = Column(Integer, ForeignKey("service.id", ondelete="CASCADE")) + + UniqueConstraint("service_id", "resource", "action", name="uniq_permission_resource_and_action") + + +class Group(Base): + __tablename__ = "group" + id = Column(Integer, primary_key=True) + name = Column(String, nullable=False, unique=True) + + org_id = Column(Integer, ForeignKey("organisation.id", ondelete="CASCADE")) + + +class GroupPermissions(Base): + __tablename__ = "group_permissions" + group_id = Column(Integer, ForeignKey("group.id", ondelete="CASCADE"), primary_key=True) + permission_id = Column(Integer, ForeignKey("permission.id", ondelete="CASCADE"), primary_key=True) + + +class UserGroups(Base): + __tablename__ = "user_groups" + org_id = Column(Integer, ForeignKey("organisation.id", ondelete="CASCADE"), primary_key=True) + user_id = Column(Integer, ForeignKey("user.id", ondelete="CASCADE"), primary_key=True) + group_id = Column(Integer, ForeignKey("group.id", ondelete="CASCADE"), primary_key=True) diff --git a/src/iam/router.py b/src/iam/router.py new file mode 100644 index 0000000..17e6673 --- /dev/null +++ b/src/iam/router.py @@ -0,0 +1,192 @@ +""" +Router endpoints for + +Endpoints: + - List: Description + - Endpoints: Description +""" +from typing import Annotated, Optional + +from fastapi import APIRouter, Query, HTTPException + +from src.database import db_dependency +from src.schemas import ResourceName +from src.auth.service import claims_dependency +from src.user.models import User +from src.organisation.models import Organisation as Org +from src.service.models import Service +from src.organisation.dependencies import org_model_dependency + +from src.iam.service import service_key_dependency +from src.iam.models import Permission as Perm, GroupPermissions as GPerms, Group, UserGroups + +router = APIRouter( + tags=["IAM"], + prefix="/iam", +) + + +@router.post("/can_act_on_resource") +async def can_act_on_resource(valid_key: service_key_dependency, db: db_dependency, user_claims: claims_dependency, + rn: ResourceName, action: str) -> bool: + try: + user_id = user_claims["db_id"] + rn_org = rn.organisation + rn_service = rn.service + rn_resource = rn.resource + + result = (db.query(Perm) + .join(Service, Service.id == Perm.service_id) + .join(GPerms, GPerms.permission_id == Perm.id) + .join(Group, Group.id == GPerms.group_id) + .join(Org, Org.id == Group.org_id) + .join(UserGroups, UserGroups.group_id == Group.id) + .join(User, User.id == UserGroups.user_id) + .filter(User.id == user_id) + .filter(Org.name == rn_org) + .filter(Service.name == rn_service) + .filter(Perm.resource == rn_resource) + .filter(Perm.action == action) + ).first() + + if result: + return True + else: + return False + except Exception as e: + print(e) + raise HTTPException(status_code=500, detail="Internal server error") + + +@router.get("/group/permissions") +async def get_group_permissions(db: db_dependency, group_id: Annotated[int, Query(gt=0)]): + # TODO: iam_admin_dependency + group_perms = db.query(Perm).join(GPerms).filter(GPerms.group_id==group_id).all() + + # TODO: Response model + return group_perms + + +@router.get("/group/users") +async def get_group_users(db: db_dependency, group_id: Annotated[int, Query(gt=0)]): + # TODO: iam_admin_dependency + group_users = db.query(User).join(UserGroups).filter(UserGroups.group_id == group_id).all() + + # TODO: Response model + return group_users + + +@router.post("/group") +async def create_group(db: db_dependency, group_name: str, org_model: org_model_dependency, org_id: int): + # TODO: iam_admin_dependency + # TODO: Request model + group_model = Group(name=group_name, org_id=org_id) + + db.add(group_model) + db.commit() + # TODO: Response model + + +@router.put("/group/permissions") +async def add_group_permissions(db: db_dependency, group_id: int, permission_id: int, org_model: org_model_dependency, org_id: int): + # TODO: iam_admin_dependency + # TODO: Request model + g_perm_model = GPerms(group_id=group_id, permission_id=permission_id) + + db.add(g_perm_model) + db.commit() + # TODO: Response model + + +@router.put("/group/users") +async def add_group_users(db: db_dependency, group_id: int, user_ids: list[int], org_model: org_model_dependency, org_id: int): + # TODO: iam_admin_dependency + # TODO: Request model + for user_id in user_ids: + user_group_model = UserGroups(group_id=group_id, user_id=user_id, org_id=org_id) + db.add(user_group_model) + + db.commit() + # TODO: Response model + + +@router.delete("/group/permissions") +async def remove_group_permissions(db: db_dependency, group_id: int, org_model: org_model_dependency, org_id: int, permission_id: int): + # TODO: iam_admin_dependency + # TODO: Request model + g_perm_model = db.query(GPerms).filter(GPerms.group_id == group_id, GPerms.permission_id == permission_id).first() + if g_perm_model is None: + return + + db.delete(g_perm_model) + db.commit() + return + # TODO: Response model + + +@router.delete("/group/user") +async def remove_group_user(db: db_dependency, group_id: int, user_id: int, org_model: org_model_dependency, org_id: int): + # TODO: iam_admin_dependency + # TODO: Request model + user_group_model = db.query(UserGroups).filter(UserGroups.group_id == group_id, UserGroups.user_id == user_id).first() + if user_group_model is None: + return + + db.delete(user_group_model) + db.commit() + return + # TODO: Response model + + +@router.get("/permissions") +async def get_permissions(db: db_dependency, org_model: org_model_dependency, org_id: int): + # TODO: iam_admin_dependency + # TODO: request model + permission_models = db.query(Perm).all() + + # TODO: Response model + return permission_models + + +@router.post("/permission") +async def create_new_permission(db: db_dependency, service_id: int, resource: str, action: str): + # TODO: super_admin_dependency + perm_model = Perm(service_id=service_id, resource=resource, action=action) + + db.add(perm_model) + db.commit() + + +@router.delete("/permission") +async def delete_permission(db: db_dependency, service_id: int, resource: str, action: str, org_model: org_model_dependency, org_id: int): + # TODO: iam_admin_dependency + # TODO: Request model + perm_model = db.query(Perm).filter(Perm.service_id==service_id, Perm.resource==resource, Perm.action==action).first() + if perm_model is None: + return + + db.delete(perm_model) + db.commit() + return + # TODO: Response model + + +@router.get("/permissions/search") +async def get_permissions(db: db_dependency, org_model: org_model_dependency, org_id: int, service_id: Optional[int] = None, resource: Optional[str] = None, action: Optional[str] = None): + # TODO: iam_admin_dependency + # TODO: request model + permission_query = db.query(Perm) + + if service_id is not None: + permission_query = permission_query.filter(Perm.service_id == service_id) + + if resource is not None: + permission_query = permission_query.filter(Perm.resource == resource) + + if action is not None: + permission_query = permission_query.filter(Perm.action == action) + + permission_models = permission_query.all() + + # TODO: Response model + return permission_models diff --git a/src/iam/schemas.py b/src/iam/schemas.py new file mode 100644 index 0000000..a074c75 --- /dev/null +++ b/src/iam/schemas.py @@ -0,0 +1,7 @@ +""" +Pydantic models for + +Models: + - List: Description + - Models: Description +""" \ No newline at end of file diff --git a/src/iam/service.py b/src/iam/service.py new file mode 100644 index 0000000..1607cd0 --- /dev/null +++ b/src/iam/service.py @@ -0,0 +1,26 @@ +""" +Module specific business logic for + +Exports service_key_dependency +""" +from typing import Annotated + +from src.service.models import Service +from src.database import db_dependency +from src.schemas import ResourceName + +from fastapi import HTTPException, status, Request, Depends + + +def valid_service_key(db: db_dependency, request: Request, rn: ResourceName) -> bool: + api_key = request.headers.get("X-API-Key", None) + if not api_key: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) + service = rn.service + result = db.query(Service).filter(Service.name == service).filter(Service.api_key == api_key).first() + if result is None: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) + + return True + +service_key_dependency = Annotated[bool, Depends(valid_service_key)] diff --git a/src/iam/utils.py b/src/iam/utils.py new file mode 100644 index 0000000..5afbb54 --- /dev/null +++ b/src/iam/utils.py @@ -0,0 +1,11 @@ +""" +Non-business logic reusable functions and classes for + +Classes: + - List: Description + - Classes: Description + +Functions: + - List: Description + - Functions: Description +""" diff --git a/src/organisation/dependencies.py b/src/organisation/dependencies.py index 229ec62..26a7036 100644 --- a/src/organisation/dependencies.py +++ b/src/organisation/dependencies.py @@ -8,4 +8,21 @@ Classes: Functions: - List: Description - Functions: Description -""" \ No newline at end of file +""" +from typing import Annotated + +from fastapi import HTTPException, Depends + +from src.database import db_dependency + +from src.organisation.models import Organisation as Org + + +def get_org_model(db: db_dependency, org_id: int) -> type[Org]: + org_model = db.query(Org).filter(Org.id == org_id).first() + if org_model is None: + raise HTTPException(status_code=404, detail="Organisation not found") + + return org_model + +org_model_dependency = Annotated[type[Org], Depends(get_org_model)] diff --git a/src/organisation/models.py b/src/organisation/models.py index de2b17a..7ed5d91 100644 --- a/src/organisation/models.py +++ b/src/organisation/models.py @@ -6,7 +6,7 @@ Models: billing_contact_id[fk], security_contact_id[fk], owner_contact_id[fk] - OrgUsers: org_id[fk][cpk], user_id[fk][cpk], is_admin """ -from sqlalchemy import Column, Integer, String, Boolean, ForeignKey, JSON, false +from sqlalchemy import Column, Integer, String, ForeignKey, JSON from src.database import Base @@ -15,10 +15,12 @@ class Organisation(Base): __tablename__ = "organisation" id = Column(Integer, primary_key=True) - name = Column(String) + name = Column(String, unique=True) status = Column(String, default="partial") intake_questionnaire = Column(JSON) + root_user_id = Column(Integer, ForeignKey("user.id")) + billing_contact_id = Column(Integer, ForeignKey("contact.id")) security_contact_id = Column(Integer, ForeignKey("contact.id")) owner_contact_id = Column(Integer, ForeignKey("contact.id")) @@ -29,4 +31,3 @@ class OrgUsers(Base): org_id = Column(Integer, ForeignKey("organisation.id", ondelete="CASCADE"), primary_key=True) user_id = Column(Integer, ForeignKey("user.id", ondelete="CASCADE"), primary_key=True) - is_admin = Column(Boolean, nullable=False, server_default=false()) diff --git a/src/organisation/router.py b/src/organisation/router.py index 3313158..e9af47d 100644 --- a/src/organisation/router.py +++ b/src/organisation/router.py @@ -8,22 +8,22 @@ Endpoints: - [patch]/{org_id}/status - Updates the status of an organisation - [patch]/{org_id}/contact - Assigns a contact to an organisation (as billing, security, or owner) - [get]/{org_id}/users - Retrieves all users associated with an organisation - - [get]/{org_id}/users/admins - Retrieves only the admin users of an organisation - [post]/{org_id}/users - Adds a new user to an organisation - - [patch]/{org_id}/users - Updates details of an existing organisation user (e.g., admin status) - [delete]/{org_id} - Deletes an organisation by ID - [get]/{org_id}/contact/{contact_type} - Retrieves the contact of a specific type (owner, billing, security) for an organisation """ from typing import Annotated -from fastapi import APIRouter, HTTPException -from fastapi.params import Path +from fastapi import APIRouter, HTTPException, status +from fastapi.params import Path, Query from sqlalchemy.sql import exists from src.database import db_dependency from src.contact.models import Contact +from src.iam.models import Group +from src.organisation.dependencies import org_model_dependency from src.organisation.constants import ContactType from src.organisation.models import Organisation as Org, OrgUsers from src.organisation.schemas import OrgOrgPostRequest, OrgQuestionnairePatchRequest, OrgStatusPatchRequest, \ @@ -127,17 +127,6 @@ async def get_users(db: db_dependency, org_id: Annotated[int, Path(gt=0)]): return org_user_models -@router.get("/{org_id}/users/admins", response_model=list[OrgUserGetResponse]) -async def get_admin_users(db: db_dependency, org_id: Annotated[int, Path(gt=0)]): - org_exists = db.query(exists().where(Org.id == org_id)).scalar() - if not org_exists: - raise HTTPException(status_code=404, detail="Organisation not found") - - org_user_models = db.query(OrgUsers).filter(OrgUsers.org_id == org_id).filter(OrgUsers.is_admin == True).all() - - return org_user_models - - @router.post("/{org_id}/users") async def add_user_to_org(db: db_dependency, user_request: OrgUserPostRequest, org_id: Annotated[int, Path(gt=0)]): org_model = (db.query(Org).filter(Org.id == org_id).first()) @@ -150,27 +139,6 @@ async def add_user_to_org(db: db_dependency, user_request: OrgUserPostRequest, o db.commit() -@router.patch("/{org_id}/users") -async def update_user_details(db: db_dependency, user_request: OrgUserPostRequest, org_id: Annotated[int, Path(gt=0)]): - """ - Currently used only to update user admin status for organisation. - """ - org_model = (db.query(Org).filter(Org.id == org_id).first()) - if org_model is None: - raise HTTPException(status_code=404, detail="Organisation not found") - - org_user_model = db.query(OrgUsers).filter(OrgUsers.org_id == org_id).filter(OrgUsers.user_id == user_request.user_id).first() - - if org_user_model is None: - raise HTTPException(status_code=404, detail="Organisation user not found") - - if user_request.is_admin is not None: - org_user_model.is_admin = user_request.is_admin - - db.add(org_user_model) - db.commit() - - @router.delete("/{org_id}") async def delete_organisation_by_id(db: db_dependency, org_id: Annotated[int, Path(gt=0)]): org_model = (db.query(Org).filter(Org.id == org_id).first()) @@ -201,3 +169,39 @@ async def get_contact(db: db_dependency, contact_type: ContactType, org_id: Anno raise HTTPException(status_code=404, detail="Contact not found") return contact_model + + +@router.get("/{org_id}/root_user") +async def get_org_root_user(db: db_dependency, org_model: org_model_dependency, org_id: Annotated[int, Path(gt=0)]): + root_user = org_model.root_user_id + + return {"root_user": root_user} + + +@router.patch("/{org_id}/root_user") +async def update_root_user(db: db_dependency, org_model: org_model_dependency, org_id: Annotated[int, Path(gt=0)], root_user: Annotated[int, Query(gt=0)]): + # TODO: Request model, ditch query + # TODO: Verify root_user exists, possibly with a user_model_dependency + org_model.root_user_id = root_user + db.add(org_model) + db.commit() + # TODO: Response model + + +@router.get("/{org_id}/groups") +async def get_org_groups(db: db_dependency, org_id: Annotated[int, Path(gt=0)]): + org_group_models = db.query(Group).filter(Group.org_id == org_id).all() + + # TODO: Response model + return org_group_models + +@router.delete("/{org_id}/user") +async def remove_user_from_org(db: db_dependency, org_model: org_model_dependency, org_id: Annotated[int, Path(gt=0)], user_id: Annotated[int, Query(gt=0)]): + orguser_model = db.query(OrgUsers).filter(OrgUsers.org_id == org_id, OrgUsers.user_id == user_id).first() + + if orguser_model is None: + raise HTTPException(status_code=status.HTTP_204_NO_CONTENT) + + db.delete(orguser_model) + db.commit() + pass diff --git a/src/organisation/schemas.py b/src/organisation/schemas.py index b4f5019..3801195 100644 --- a/src/organisation/schemas.py +++ b/src/organisation/schemas.py @@ -38,11 +38,9 @@ class OrgContactPatchRequest(CustomBaseModel): class OrgUserPostRequest(CustomBaseModel): user_id: int - is_admin: Optional[bool] = False class OrgUserGetResponse(CustomBaseModel): user_id: int - is_admin: bool class OrgContactGetResponse(CustomBaseModel): email: str diff --git a/src/schemas.py b/src/schemas.py index e524f83..52b0f94 100644 --- a/src/schemas.py +++ b/src/schemas.py @@ -1,5 +1,13 @@ from pydantic import BaseModel +from typing import Optional class CustomBaseModel(BaseModel): pass + + +class ResourceName(CustomBaseModel): + service: str + organisation: str + resource: str + instance: Optional[str] = None diff --git a/src/service/config.py b/src/service/config.py new file mode 100644 index 0000000..4be170e --- /dev/null +++ b/src/service/config.py @@ -0,0 +1,7 @@ +""" +Configurations for + +Configurations: + - List: Description + - Configs: Description +""" \ No newline at end of file diff --git a/src/service/constants.py b/src/service/constants.py new file mode 100644 index 0000000..e1df957 --- /dev/null +++ b/src/service/constants.py @@ -0,0 +1,7 @@ +""" +Constants and error codes for + +Constants: + - List: Description + - Consts: Description +""" \ No newline at end of file diff --git a/src/service/dependencies.py b/src/service/dependencies.py new file mode 100644 index 0000000..7447aaf --- /dev/null +++ b/src/service/dependencies.py @@ -0,0 +1,11 @@ +""" +Router dependencies for + +Classes: + - List: Description + - Classes: Description + +Functions: + - List: Description + - Functions: Description +""" \ No newline at end of file diff --git a/src/service/exceptions.py b/src/service/exceptions.py new file mode 100644 index 0000000..5debbb4 --- /dev/null +++ b/src/service/exceptions.py @@ -0,0 +1,7 @@ +""" +Module specific exceptions for + +Exceptions: + - List: Description + - Exceptions: Description +""" \ No newline at end of file diff --git a/src/service/models.py b/src/service/models.py new file mode 100644 index 0000000..3f05e1d --- /dev/null +++ b/src/service/models.py @@ -0,0 +1,18 @@ +""" +Database models for the services module + +Models: + - List: Description + - Models: Description +""" +from sqlalchemy import Column, Integer, String + +from src.database import Base + + +class Service(Base): + __tablename__ = "service" + + id = Column(Integer, primary_key=True) + name = Column(String, unique=True) + api_key = Column(String, unique=True) diff --git a/src/service/router.py b/src/service/router.py new file mode 100644 index 0000000..7617dc0 --- /dev/null +++ b/src/service/router.py @@ -0,0 +1,63 @@ +""" +Router endpoints for + +Endpoints: + - List: Description + - Endpoints: Description +""" +from fastapi import APIRouter + +from src.database import db_dependency + +from src.service.models import Service +from src.service.utils import generate_api_key + + +router = APIRouter( + tags=["Service"], + prefix="/service", +) + +@router.get("/") +async def get_all_services(db: db_dependency): + # TODO: user_dependency + # TODO: request model + permission_models = db.query(Service).all() + + # TODO: Response model + return permission_models + +@router.post("/") +async def register_service(db: db_dependency, service_name: str): + # TODO: super_admin_dependency + # TODO: request model + key = generate_api_key() + service_model = Service(name=service_name, api_key=key) + + db.add(service_model) + db.commit() + # TODO: response model + +@router.patch("/{service_id}/key") +async def regenerate_api_key(db: db_dependency, service_id: int): + # TODO: super_admin_dependency + # TODO: request model + key = generate_api_key() + service_model = db.query(Service).filter(Service.id==service_id).first() + service_model.api_key = key + + db.add(service_model) + db.commit() + # TODO: response model + +@router.delete("/{service_id}") +async def remove_service(db: db_dependency, service_id: int): + # TODO: super_admin_dependency + # TODO: request model + service_model = db.query(Service).filter(Service.id==service_id).first() + if service_model is None: + return + + db.delete(service_model) + db.commit() + # TODO: response model diff --git a/src/service/schemas.py b/src/service/schemas.py new file mode 100644 index 0000000..a074c75 --- /dev/null +++ b/src/service/schemas.py @@ -0,0 +1,7 @@ +""" +Pydantic models for + +Models: + - List: Description + - Models: Description +""" \ No newline at end of file diff --git a/src/service/service.py b/src/service/service.py new file mode 100644 index 0000000..7365fa9 --- /dev/null +++ b/src/service/service.py @@ -0,0 +1,11 @@ +""" +Module specific business logic for + +Classes: + - List: Description + - Classes: Description + +Functions: + - List: Description + - Functions: Description +""" \ No newline at end of file diff --git a/src/service/utils.py b/src/service/utils.py new file mode 100644 index 0000000..ecd41b0 --- /dev/null +++ b/src/service/utils.py @@ -0,0 +1,16 @@ +""" +Non-business logic reusable functions and classes for + +Classes: + - List: Description + - Classes: Description + +Functions: + - List: Description + - Functions: Description +""" +import uuid + + +def generate_api_key() -> str: + return str(uuid.uuid4()) diff --git a/src/user/router.py b/src/user/router.py index fb2b632..90e63ac 100644 --- a/src/user/router.py +++ b/src/user/router.py @@ -23,7 +23,7 @@ from src.user.schemas import UserResponse, OrgResponse, OIDCClaims from src.user.exceptions import UserNotFoundException from src.organisation.models import OrgUsers, Organisation - +from src.iam.models import Group, UserGroups from src.auth.service import claims_dependency from src.database import db_dependency @@ -78,7 +78,7 @@ async def get_current_organisations(db: db_dependency, user: claims_dependency): if not user_exists: raise UserNotFoundException(user_id=user_id) - org_user_models = (db.query(OrgUsers.org_id, OrgUsers.is_admin, Organisation.name) + org_user_models = (db.query(OrgUsers.org_id, Organisation.name) .join(OrgUsers, Organisation.id == OrgUsers.org_id) .filter(OrgUsers.user_id == user_id) .all() @@ -87,31 +87,6 @@ async def get_current_organisations(db: db_dependency, user: claims_dependency): return org_user_models -@router.get("/self/orgs/admin", response_model=list[OrgResponse], status_code=status.HTTP_200_OK, responses={ - status.HTTP_404_NOT_FOUND: {"description": "User not found"}, - status.HTTP_200_OK: {"description": "Successful retrieval from database"}, -}) -async def get_current_admin_organisations(db: db_dependency, user: claims_dependency): - """ - Returns the organisations for which the currently logged-in user is an admin. - """ - user_id = user.get("db_id", None) - if user_id is None: - raise UserNotFoundException() - user_exists = db.query(exists().where(User.id == user_id)).scalar() - if not user_exists: - raise UserNotFoundException(user_id=user_id) - - org_user_models = (db.query(OrgUsers.org_id, OrgUsers.is_admin, Organisation.name) - .join(OrgUsers, Organisation.id == OrgUsers.org_id) - .filter(OrgUsers.user_id == user_id) - .filter(OrgUsers.is_admin == True) - .all() - ) - - return org_user_models - - @router.get("/{user_id}", response_model=UserResponse, status_code=status.HTTP_200_OK, responses={ status.HTTP_404_NOT_FOUND: {"description": "User not found"}, status.HTTP_200_OK: {"description": "Successful retrieval from database"}, @@ -139,7 +114,7 @@ async def get_organisations(db: db_dependency, user_id: Annotated[int, Path(gt=0 if not user_exists: raise UserNotFoundException(user_id=user_id) - org_user_models = (db.query(OrgUsers.org_id, OrgUsers.is_admin, Organisation.name) + org_user_models = (db.query(OrgUsers.org_id, Organisation.name) .join(OrgUsers, Organisation.id == OrgUsers.org_id) .filter(OrgUsers.user_id == user_id) .all() @@ -148,28 +123,6 @@ async def get_organisations(db: db_dependency, user_id: Annotated[int, Path(gt=0 return org_user_models -@router.get("/{user_id}/orgs/admin", response_model=list[OrgResponse], status_code=status.HTTP_200_OK, responses={ - status.HTTP_404_NOT_FOUND: {"description": "User not found"}, - status.HTTP_200_OK: {"description": "Successful retrieval from database"}, -}) -async def get_admin_organisations(db: db_dependency, user_id: Annotated[int, Path(gt=0,description="User database ID")]): - """ - Returns the organisations for which the user with the provided user ID is an admin. - """ - user_exists = db.query(exists().where(User.id == user_id)).scalar() - if not user_exists: - raise UserNotFoundException(user_id=user_id) - - org_user_models = (db.query(OrgUsers.org_id, OrgUsers.is_admin, Organisation.name) - .join(OrgUsers, Organisation.id == OrgUsers.org_id) - .filter(OrgUsers.user_id == user_id) - .filter(OrgUsers.is_admin == True) - .all() - ) - - return org_user_models - - @router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT, responses={ status.HTTP_204_NO_CONTENT: {"description": "User deleted"}, status.HTTP_404_NOT_FOUND: {"description": "User not found"}, @@ -183,3 +136,15 @@ async def delete_user_by_id(user_id: Annotated[int, Path(gt=0)], db: db_dependen raise UserNotFoundException(user_id=user_id) db.delete(user_model) db.commit() + + +@router.get("/{user_id}/groups") +async def get_user_groups(db: db_dependency, user_id: Annotated[int, Path(gt=0,description="User database ID")]): + user_model = (db.query(User).filter(User.id == user_id).first()) + if user_model is None: + raise UserNotFoundException(user_id=user_id) + + user_groups = db.query(Group).join(UserGroups).filter(UserGroups.user_id==user_id).all() + + # TODO: Response model + return user_groups diff --git a/src/user/schemas.py b/src/user/schemas.py index 2dd29ab..f33ae54 100644 --- a/src/user/schemas.py +++ b/src/user/schemas.py @@ -49,4 +49,3 @@ class UserResponse(CustomBaseModel): class OrgResponse(CustomBaseModel): org_id: int name: str - is_admin: bool From 804e21b871a56cbfa14311c197f3a4d5d49baeb0 Mon Sep 17 00:00:00 2001 From: luxferre Date: Mon, 25 May 2026 09:32:40 +0100 Subject: [PATCH 03/10] fix: handling for unset org contacts --- src/organisation/schemas.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/organisation/schemas.py b/src/organisation/schemas.py index 3801195..68f147e 100644 --- a/src/organisation/schemas.py +++ b/src/organisation/schemas.py @@ -52,6 +52,6 @@ class OrgContactGetResponse(CustomBaseModel): class OrgOrgGetResponse(CustomBaseModel): name: str status: Status - owner_contact: OrgContactGetResponse - billing_contact: OrgContactGetResponse - security_contact: OrgContactGetResponse + owner_contact: Optional[OrgContactGetResponse] = None + billing_contact: Optional[OrgContactGetResponse] = None + security_contact: Optional[OrgContactGetResponse] = None From 2a20172d784cc678b980e7a384e92359893c9670 Mon Sep 17 00:00:00 2001 From: luxferre Date: Mon, 25 May 2026 09:54:19 +0100 Subject: [PATCH 04/10] fix: questionnaire patch route update for new model --- src/organisation/router.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/organisation/router.py b/src/organisation/router.py index e9af47d..a62dbfc 100644 --- a/src/organisation/router.py +++ b/src/organisation/router.py @@ -22,6 +22,7 @@ from sqlalchemy.sql import exists from src.database import db_dependency from src.contact.models import Contact from src.iam.models import Group +from src.auth.service import root_user_dependency from src.organisation.dependencies import org_model_dependency from src.organisation.constants import ContactType @@ -55,6 +56,7 @@ async def get_org_by_id(db: db_dependency, org_id: Annotated[int, Path(gt=0)]): @router.post("/") async def create_org(db: db_dependency, org_request: OrgOrgPostRequest): + # TODO: Root user from current user org_model = Org(**org_request.model_dump()) org_model.status = "partial" # Status is always set to partial at first, see update_questionnaire() doc @@ -74,7 +76,7 @@ async def update_questionnaire(db: db_dependency, q_request: OrgQuestionnairePat if org_model is None: raise HTTPException(status_code=404, detail="Organisation not found") - org_model.intake_questionnaire = q_request.intake_questionnaire + org_model.intake_questionnaire = q_request.intake_questionnaire.model_dump() # Allows for partially completed questionnaires to be saved without being submitted for review if not q_request.partial: From d51adb4e5501830081abce8a1d4c7cf2f28efa03 Mon Sep 17 00:00:00 2001 From: luxferre Date: Mon, 25 May 2026 09:54:46 +0100 Subject: [PATCH 05/10] feat: org root user dependency --- src/auth/service.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/auth/service.py b/src/auth/service.py index 40a696b..6f9cc97 100644 --- a/src/auth/service.py +++ b/src/auth/service.py @@ -20,7 +20,9 @@ from sqlalchemy.sql import exists from src.auth.config import auth_settings from src.user.service import add_user_to_db from src.organisation.models import OrgUsers, Organisation as Org +from src.user.models import User from src.database import db_dependency +from src.organisation.dependencies import org_model_dependency oidc = OpenIdConnect(openIdConnectUrl=auth_settings.OIDC_CONFIG) @@ -88,6 +90,20 @@ async def is_org_user(claims: claims_dependency, db: db_dependency, org_id: int org_user_dependency = Annotated[dict[str, Any], Depends(is_org_user)] +async def is_org_root(claims: claims_dependency, db: db_dependency, org_model: org_model_dependency, org_id: int = Path(gt=0)): + db_id = claims.get("db_id", None) + if db_id is None: + raise HTTPException(status_code=404, detail="User not found in db") + + if org_model.root_user_id == db_id: + return db.query(User).filter(User.id == db_id).first() + + raise HTTPException(status_code=401, detail="Not authorised") + + +root_user_dependency = Annotated[dict[str, Any], Depends(is_org_root)] + + async def is_super_admin(claims: claims_dependency): super_admin_ids = [] From 4ff184fe86d8fc78581bdfbb00bb5aacf7a5a0fe Mon Sep 17 00:00:00 2001 From: luxferre Date: Mon, 25 May 2026 10:21:15 +0100 Subject: [PATCH 06/10] feat: sqlalchemy defined orgusers relationship --- src/organisation/models.py | 7 +++++++ src/user/models.py | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/src/organisation/models.py b/src/organisation/models.py index 7ed5d91..ceae8f7 100644 --- a/src/organisation/models.py +++ b/src/organisation/models.py @@ -7,6 +7,7 @@ Models: - OrgUsers: org_id[fk][cpk], user_id[fk][cpk], is_admin """ from sqlalchemy import Column, Integer, String, ForeignKey, JSON +from sqlalchemy.orm import relationship from src.database import Base @@ -25,6 +26,12 @@ class Organisation(Base): security_contact_id = Column(Integer, ForeignKey("contact.id")) owner_contact_id = Column(Integer, ForeignKey("contact.id")) + users = relationship( + "User", + secondary="orgusers", + back_populates="organisations" + ) + class OrgUsers(Base): __tablename__ = "orgusers" diff --git a/src/user/models.py b/src/user/models.py index 89801d5..7bce630 100644 --- a/src/user/models.py +++ b/src/user/models.py @@ -5,6 +5,7 @@ Models: - User - id[pk], email, first_name, last_name, oidc_id """ from sqlalchemy import Column, Integer, String +from sqlalchemy.orm import relationship from src.database import Base @@ -17,3 +18,9 @@ class User(Base): first_name = Column(String) last_name = Column(String) oidc_id = Column(String, index=True, unique=True) + + organisations = relationship( + "Organisation", + secondary="orgusers", + back_populates="users" + ) From a80767d870807b362c782d3face6e9e74fea5fbc Mon Sep 17 00:00:00 2001 From: luxferre Date: Mon, 25 May 2026 12:06:24 +0100 Subject: [PATCH 07/10] feat: condensed user get endpoints The process also added improved ORM relationships for multiple models. --- src/iam/models.py | 9 ++++++ src/organisation/models.py | 6 ++-- src/user/models.py | 24 +++++++++++++-- src/user/router.py | 62 +------------------------------------- src/user/schemas.py | 3 ++ 5 files changed, 39 insertions(+), 65 deletions(-) diff --git a/src/iam/models.py b/src/iam/models.py index 5ec6065..6e9ca70 100644 --- a/src/iam/models.py +++ b/src/iam/models.py @@ -6,6 +6,7 @@ Models: - Models: Description """ from sqlalchemy import Column, Integer, String, ForeignKey, UniqueConstraint +from sqlalchemy.orm import relationship from src.database import Base @@ -29,6 +30,14 @@ class Group(Base): org_id = Column(Integer, ForeignKey("organisation.id", ondelete="CASCADE")) + user_rel = relationship( + "User", + secondary="user_groups", + back_populates="group_rel" + ) + + org_rel = relationship("Organisation", back_populates="group_rel") + class GroupPermissions(Base): __tablename__ = "group_permissions" diff --git a/src/organisation/models.py b/src/organisation/models.py index ceae8f7..c85844a 100644 --- a/src/organisation/models.py +++ b/src/organisation/models.py @@ -26,12 +26,14 @@ class Organisation(Base): security_contact_id = Column(Integer, ForeignKey("contact.id")) owner_contact_id = Column(Integer, ForeignKey("contact.id")) - users = relationship( + user_rel = relationship( "User", secondary="orgusers", - back_populates="organisations" + back_populates="organisation_rel" ) + group_rel = relationship("Group", back_populates="org_rel") + class OrgUsers(Base): __tablename__ = "orgusers" diff --git a/src/user/models.py b/src/user/models.py index 7bce630..eac4684 100644 --- a/src/user/models.py +++ b/src/user/models.py @@ -4,10 +4,13 @@ Database models for user module Models: - User - id[pk], email, first_name, last_name, oidc_id """ +from collections import defaultdict + from sqlalchemy import Column, Integer, String from sqlalchemy.orm import relationship from src.database import Base +from src.iam.models import Group class User(Base): @@ -19,8 +22,25 @@ class User(Base): last_name = Column(String) oidc_id = Column(String, index=True, unique=True) - organisations = relationship( + organisation_rel = relationship( "Organisation", secondary="orgusers", - back_populates="users" + back_populates="user_rel" ) + + @property + def organisations(self): + return [org.name for org in self.organisation_rel] + + group_rel = relationship( + "Group", + secondary="user_groups", + back_populates="user_rel" + ) + + @property + def groups(self): + result = defaultdict(list) + for group in self.group_rel: + result[group.org_rel.name].append(group.name) + return dict(result) diff --git a/src/user/router.py b/src/user/router.py index 90e63ac..6d7a5a8 100644 --- a/src/user/router.py +++ b/src/user/router.py @@ -15,15 +15,12 @@ from typing import Annotated from fastapi import APIRouter from fastapi.params import Path -from sqlalchemy.sql import exists from starlette import status from src.user.models import User -from src.user.schemas import UserResponse, OrgResponse, OIDCClaims +from src.user.schemas import UserResponse, OIDCClaims from src.user.exceptions import UserNotFoundException -from src.organisation.models import OrgUsers, Organisation -from src.iam.models import Group, UserGroups from src.auth.service import claims_dependency from src.database import db_dependency @@ -63,30 +60,6 @@ async def current_user(user: claims_dependency, db: db_dependency): return user_model -@router.get("/self/orgs", response_model=list[OrgResponse], status_code=status.HTTP_200_OK, responses={ - status.HTTP_404_NOT_FOUND: {"description": "User not found"}, - status.HTTP_200_OK: {"description": "Successful retrieval from database"}, -}) -async def get_current_organisations(db: db_dependency, user: claims_dependency): - """ - Returns all organisations associated with the currently logged-in user. - """ - user_id = user.get("db_id", None) - if user_id is None: - raise UserNotFoundException() - user_exists = db.query(exists().where(User.id == user_id)).scalar() - if not user_exists: - raise UserNotFoundException(user_id=user_id) - - org_user_models = (db.query(OrgUsers.org_id, Organisation.name) - .join(OrgUsers, Organisation.id == OrgUsers.org_id) - .filter(OrgUsers.user_id == user_id) - .all() - ) - - return org_user_models - - @router.get("/{user_id}", response_model=UserResponse, status_code=status.HTTP_200_OK, responses={ status.HTTP_404_NOT_FOUND: {"description": "User not found"}, status.HTTP_200_OK: {"description": "Successful retrieval from database"}, @@ -102,27 +75,6 @@ async def get_user_by_id(db: db_dependency, user_id: Annotated[int, Path(gt=0,de return user_model -@router.get("/{user_id}/orgs", response_model=list[OrgResponse], status_code=status.HTTP_200_OK, responses={ - status.HTTP_404_NOT_FOUND: {"description": "User not found"}, - status.HTTP_200_OK: {"description": "Successful retrieval from database"}, -}) -async def get_organisations(db: db_dependency, user_id: Annotated[int, Path(gt=0,description="User database ID")]): - """ - Returns all organisations associated with the provided user ID. - """ - user_exists = db.query(exists().where(User.id == user_id)).scalar() - if not user_exists: - raise UserNotFoundException(user_id=user_id) - - org_user_models = (db.query(OrgUsers.org_id, Organisation.name) - .join(OrgUsers, Organisation.id == OrgUsers.org_id) - .filter(OrgUsers.user_id == user_id) - .all() - ) - - return org_user_models - - @router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT, responses={ status.HTTP_204_NO_CONTENT: {"description": "User deleted"}, status.HTTP_404_NOT_FOUND: {"description": "User not found"}, @@ -136,15 +88,3 @@ async def delete_user_by_id(user_id: Annotated[int, Path(gt=0)], db: db_dependen raise UserNotFoundException(user_id=user_id) db.delete(user_model) db.commit() - - -@router.get("/{user_id}/groups") -async def get_user_groups(db: db_dependency, user_id: Annotated[int, Path(gt=0,description="User database ID")]): - user_model = (db.query(User).filter(User.id == user_id).first()) - if user_model is None: - raise UserNotFoundException(user_id=user_id) - - user_groups = db.query(Group).join(UserGroups).filter(UserGroups.user_id==user_id).all() - - # TODO: Response model - return user_groups diff --git a/src/user/schemas.py b/src/user/schemas.py index f33ae54..7e3dc12 100644 --- a/src/user/schemas.py +++ b/src/user/schemas.py @@ -5,6 +5,7 @@ Models: - List: Description - Models: Description """ +from typing import Optional from src.schemas import CustomBaseModel @@ -44,6 +45,8 @@ class UserResponse(CustomBaseModel): first_name: str last_name: str email: str + organisations: list[Optional[str]] + groups: dict[str, list[str]] class OrgResponse(CustomBaseModel): From 707482adc233d0e1c55534b3dfd5596002b51e15 Mon Sep 17 00:00:00 2001 From: luxferre Date: Mon, 25 May 2026 12:40:28 +0100 Subject: [PATCH 08/10] feat: condensed org get endpoints The process also added improved ORM relationships for multiple models. --- src/organisation/models.py | 5 +++++ src/organisation/router.py | 14 ++------------ src/organisation/schemas.py | 1 + 3 files changed, 8 insertions(+), 12 deletions(-) diff --git a/src/organisation/models.py b/src/organisation/models.py index c85844a..58be79f 100644 --- a/src/organisation/models.py +++ b/src/organisation/models.py @@ -33,6 +33,11 @@ class Organisation(Base): ) group_rel = relationship("Group", back_populates="org_rel") + root_user_rel = relationship("User", foreign_keys=[root_user_id]) + + @property + def root_user_email(self): + return self.root_user_rel.email if self.root_user_rel else None class OrgUsers(Base): diff --git a/src/organisation/router.py b/src/organisation/router.py index a62dbfc..189da5f 100644 --- a/src/organisation/router.py +++ b/src/organisation/router.py @@ -38,17 +38,14 @@ router = APIRouter( @router.get("/id/{org_id}", response_model=OrgOrgGetResponse) -async def get_org_by_id(db: db_dependency, org_id: Annotated[int, Path(gt=0)]): - org_model = (db.query(Org).filter(Org.id == org_id).first()) - if org_model is None: - raise HTTPException(status_code=404, detail="Organisation not found") - +async def get_org_by_id(db: db_dependency, org_model: org_model_dependency, org_id: Annotated[int, Path(gt=0)]): response = { "name": org_model.name, "status": org_model.status, "owner_contact": (db.query(Contact).filter(Contact.id == org_model.owner_contact_id).first()), "billing_contact": (db.query(Contact).filter(Contact.id == org_model.billing_contact_id).first()), "security_contact": (db.query(Contact).filter(Contact.id == org_model.security_contact_id).first()), + "root_user": org_model.root_user_email } return response @@ -173,13 +170,6 @@ async def get_contact(db: db_dependency, contact_type: ContactType, org_id: Anno return contact_model -@router.get("/{org_id}/root_user") -async def get_org_root_user(db: db_dependency, org_model: org_model_dependency, org_id: Annotated[int, Path(gt=0)]): - root_user = org_model.root_user_id - - return {"root_user": root_user} - - @router.patch("/{org_id}/root_user") async def update_root_user(db: db_dependency, org_model: org_model_dependency, org_id: Annotated[int, Path(gt=0)], root_user: Annotated[int, Query(gt=0)]): # TODO: Request model, ditch query diff --git a/src/organisation/schemas.py b/src/organisation/schemas.py index 68f147e..86fe8f0 100644 --- a/src/organisation/schemas.py +++ b/src/organisation/schemas.py @@ -52,6 +52,7 @@ class OrgContactGetResponse(CustomBaseModel): class OrgOrgGetResponse(CustomBaseModel): name: str status: Status + root_user: Optional[str] = None owner_contact: Optional[OrgContactGetResponse] = None billing_contact: Optional[OrgContactGetResponse] = None security_contact: Optional[OrgContactGetResponse] = None From 2b6d923ae1120bd93afc4489adbda00cd90b55bb Mon Sep 17 00:00:00 2001 From: luxferre Date: Mon, 25 May 2026 15:15:50 +0100 Subject: [PATCH 09/10] feat: contact model restructure Blank contacts are now generated on org creation and assigned to each contact type. These contacts are linked to the org, only accessible to the org, and removed when the org is removed. With this all contact endpoints have been removed. Contact manipulation is done via the org only. --- .../2026-05-25_contact_model_changes.py | 34 ++++++ src/contact/models.py | 4 +- src/contact/router.py | 104 +--------------- src/contact/schemas.py | 14 ++- src/organisation/models.py | 4 + src/organisation/router.py | 111 ++++++++++-------- src/organisation/schemas.py | 40 ++++--- 7 files changed, 146 insertions(+), 165 deletions(-) create mode 100644 .alembic/versions/2026-05-25_contact_model_changes.py diff --git a/.alembic/versions/2026-05-25_contact_model_changes.py b/.alembic/versions/2026-05-25_contact_model_changes.py new file mode 100644 index 0000000..8ad1f5a --- /dev/null +++ b/.alembic/versions/2026-05-25_contact_model_changes.py @@ -0,0 +1,34 @@ +"""Contact model changes + +Revision ID: 8132c4b88665 +Revises: a147965e644e +Create Date: 2026-05-25 13:09:22.635058 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '8132c4b88665' +down_revision: Union[str, Sequence[str], None] = 'a147965e644e' +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('contact', sa.Column('org_id', sa.Integer(), nullable=False)) + op.create_foreign_key(None, 'contact', 'organisation', ['org_id'], ['id'], ondelete='CASCADE') + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint(None, 'contact', type_='foreignkey') + op.drop_column('contact', 'org_id') + # ### end Alembic commands ### diff --git a/src/contact/models.py b/src/contact/models.py index 4f94601..e3d0d05 100644 --- a/src/contact/models.py +++ b/src/contact/models.py @@ -5,7 +5,7 @@ Models: - Contact: id[pk], email, first_name, last_name, phonenumber, vat_number street_address, post_office_box_number, address_locality, country_code, address_region, postal_code """ -from sqlalchemy import Column, Integer, String +from sqlalchemy import Column, Integer, String, ForeignKey from src.database import Base @@ -27,3 +27,5 @@ class Contact(Base): country_code = Column(String) # Eg GB address_region = Column(String, default=None, nullable=True) postal_code = Column(String) + + org_id = Column(Integer, ForeignKey("organisation.id", ondelete="CASCADE"), nullable=False) diff --git a/src/contact/router.py b/src/contact/router.py index 3ed9a0f..9528fd9 100644 --- a/src/contact/router.py +++ b/src/contact/router.py @@ -9,110 +9,10 @@ Endpoints: - [patch]/{contact_id} - Updates the details of an existing contact - [delete]/{contact_id} - Deletes a contact by ID """ -from typing import Annotated +from fastapi import APIRouter -from fastapi import APIRouter, HTTPException -from fastapi.params import Path - -from sqlalchemy import or_ - -from src.contact.schemas import ContactContactGetResponse, ContactAddressGetResponse, ContactContactPostRequest, \ - ContactUpdateRequest, ContactOrgGetResponse -from src.contact.models import Contact - -from src.database import db_dependency -from src.organisation.models import Organisation as Org -from src.organisation.constants import ContactType router = APIRouter( prefix="/contact", tags=["contact"], -) - - -@router.get("/{contact_id}", response_model=ContactContactGetResponse) -async def get_contact_details_by_id(contact_id: int, db: db_dependency): - contact_model = (db.query(Contact).filter(Contact.id == contact_id).first()) - if contact_model is None: - raise HTTPException(status_code=404, detail="Contact not found") - - return contact_model - - -@router.get("/{contact_id}/address", response_model=ContactAddressGetResponse) -async def get_contact_address_by_id(contact_id: int, db: db_dependency): - contact_model = (db.query(Contact).filter(Contact.id == contact_id).first()) - if contact_model is None: - raise HTTPException(status_code=404, detail="Contact not found") - - return contact_model - - -@router.post("/") -async def create_contact(db: db_dependency, contact_request: ContactContactPostRequest): - contact_model = Contact(**contact_request.model_dump()) - - db.add(contact_model) - db.commit() - - -@router.patch("/{contact_id}") -async def update_contact(db: db_dependency, contact_request: ContactUpdateRequest, contact_id: Annotated[int, Path(gt=0)]): - contact_model = (db.query(Contact).filter(Contact.id == contact_id).first()) - if contact_model is None: - raise HTTPException(status_code=404, detail="Contact not found") - - update_data = contact_request.model_dump(exclude_none=True) - for key, value in update_data.items(): - if hasattr(contact_model, key): - setattr(contact_model, key, value) - else: - raise HTTPException(status_code=422, detail="Invalid keys in update request") - - db.add(contact_model) - db.commit() - - -@router.delete("/{contact_id}") -async def delete_contact(db: db_dependency, contact_id: Annotated[int, Path(gt=0)]): - contact_model = (db.query(Contact).filter(Contact.id == contact_id).first()) - if contact_model is None: - raise HTTPException(status_code=404, detail="Contact not found") - - db.delete(contact_model) - db.commit() - - -@router.get("/{contact_id}/orgs", response_model=list[ContactOrgGetResponse]) -async def get_contact_orgs(db: db_dependency, contact_id: Annotated[int, Path(gt=0)]): - contact_model = (db.query(Contact).filter(Contact.id == contact_id).first()) - if contact_model is None: - raise HTTPException(status_code=404, detail="Contact not found") - - org_models = (db.query(Org).filter( - or_( - Org.owner_contact_id == contact_id, - Org.billing_contact_id == contact_id, - Org.security_contact_id == contact_id - ) - ).all()) - - response = [] - - for org in org_models: - types=[] - if org.owner_contact_id == contact_id: - types.append(ContactType.OWNER) - if org.billing_contact_id == contact_id: - types.append(ContactType.BILLING) - if org.security_contact_id == contact_id: - types.append(ContactType.SECURITY) - - org_response_model = ContactOrgGetResponse( - name=str(org.name), - contact_types=types, - ) - response.append(org_response_model) - - - return response +) \ No newline at end of file diff --git a/src/contact/schemas.py b/src/contact/schemas.py index d64e265..c3d705c 100644 --- a/src/contact/schemas.py +++ b/src/contact/schemas.py @@ -7,12 +7,24 @@ Models: """ from typing import Optional -from pydantic import EmailStr +from pydantic import EmailStr, ConfigDict from src.organisation.constants import ContactType from src.schemas import CustomBaseModel +class ContactAddress(CustomBaseModel): + model_config = ConfigDict(from_attributes=True, extra="ignore") + + post_office_box_number: Optional[str] = None + street_address: Optional[str] = None + street_address_line_2: Optional[str] = None + locality: Optional[str] = None + address_region: Optional[str] = None + country_code: Optional[str] = None + postal_code: Optional[str] = None + + class ContactContactGetResponse(CustomBaseModel): email: str first_name: str diff --git a/src/organisation/models.py b/src/organisation/models.py index 58be79f..f696824 100644 --- a/src/organisation/models.py +++ b/src/organisation/models.py @@ -39,6 +39,10 @@ class Organisation(Base): def root_user_email(self): return self.root_user_rel.email if self.root_user_rel else None + billing_contact_rel = relationship("Contact", foreign_keys=[billing_contact_id]) + security_contact_rel = relationship("Contact", foreign_keys=[security_contact_id]) + owner_contact_rel = relationship("Contact", foreign_keys=[owner_contact_id]) + class OrgUsers(Base): __tablename__ = "orgusers" diff --git a/src/organisation/router.py b/src/organisation/router.py index 189da5f..8d6b692 100644 --- a/src/organisation/router.py +++ b/src/organisation/router.py @@ -19,6 +19,7 @@ from fastapi.params import Path, Query from sqlalchemy.sql import exists +from src.contact.schemas import ContactAddress from src.database import db_dependency from src.contact.models import Contact from src.iam.models import Group @@ -42,9 +43,9 @@ async def get_org_by_id(db: db_dependency, org_model: org_model_dependency, org_ response = { "name": org_model.name, "status": org_model.status, - "owner_contact": (db.query(Contact).filter(Contact.id == org_model.owner_contact_id).first()), - "billing_contact": (db.query(Contact).filter(Contact.id == org_model.billing_contact_id).first()), - "security_contact": (db.query(Contact).filter(Contact.id == org_model.security_contact_id).first()), + "owner_contact": org_model.owner_contact_rel.email, + "billing_contact": org_model.billing_contact_rel.email, + "security_contact": org_model.security_contact_rel.email, "root_user": org_model.root_user_email } @@ -54,11 +55,17 @@ async def get_org_by_id(db: db_dependency, org_model: org_model_dependency, org_ @router.post("/") async def create_org(db: db_dependency, org_request: OrgOrgPostRequest): # TODO: Root user from current user - org_model = Org(**org_request.model_dump()) + org_model = Org(name=org_request.name, intake_questionnaire=org_request.intake_questionnaire) org_model.status = "partial" # Status is always set to partial at first, see update_questionnaire() doc db.add(org_model) + db.flush() + for contact_type in ["billing_contact_id", "security_contact_id", "owner_contact_id"]: + contact_model = Contact(org_id=org_model.id) + db.add(contact_model) + db.flush() + org_model.__setattr__(contact_type, contact_model.id) db.commit() @@ -95,26 +102,6 @@ async def update_status(db: db_dependency, status_request: OrgStatusPatchRequest db.commit() -@router.patch("/{org_id}/contact") -async def update_contact(db: db_dependency, contact_request: OrgContactPatchRequest, org_id: Annotated[int, Path(gt=0)]): - org_model = db.query(Org).filter(Org.id == org_id).first() - if org_model is None: - raise HTTPException(status_code=404, detail="Organisation not found") - - match contact_request.contact_type: - case "billing": - org_model.billing_contact_id = contact_request.contact_id - case "security": - org_model.security_contact_id = contact_request.contact_id - case "owner": - org_model.owner_contact_id = contact_request.contact_id - case _: - raise HTTPException(status_code=422, detail="Invalid contact type") - - db.add(org_model) - db.commit() - - @router.get("/{org_id}/users", response_model=list[OrgUserGetResponse]) async def get_users(db: db_dependency, org_id: Annotated[int, Path(gt=0)]): org_exists = db.query(exists().where(Org.id == org_id)).scalar() @@ -147,29 +134,6 @@ async def delete_organisation_by_id(db: db_dependency, org_id: Annotated[int, Pa db.commit() -@router.get("/{org_id}/contact/{contact_type}", response_model=OrgContactGetResponse) -async def get_contact(db: db_dependency, contact_type: ContactType, org_id: Annotated[int, Path(gt=0)]): - org_model = db.query(Org).filter(Org.id == org_id).first() - if org_model is None: - raise HTTPException(status_code=404, detail="Organisation not found") - - match contact_type: - case "billing": - contact_id = org_model.billing_contact_id - case "security": - contact_id = org_model.security_contact_id - case "owner": - contact_id = org_model.owner_contact_id - case _: - raise HTTPException(status_code=422, detail="Invalid contact type") - - contact_model = (db.query(Contact).filter(Contact.id == contact_id).first()) - if contact_model is None: - raise HTTPException(status_code=404, detail="Contact not found") - - return contact_model - - @router.patch("/{org_id}/root_user") async def update_root_user(db: db_dependency, org_model: org_model_dependency, org_id: Annotated[int, Path(gt=0)], root_user: Annotated[int, Query(gt=0)]): # TODO: Request model, ditch query @@ -197,3 +161,56 @@ async def remove_user_from_org(db: db_dependency, org_model: org_model_dependenc db.delete(orguser_model) db.commit() pass + + +@router.get("/{org_id}/contact", response_model=OrgContactGetResponse) +async def get_contact(db: db_dependency, org_model: org_model_dependency, contact_type: Annotated[ContactType, Query()], org_id: Annotated[int, Path(gt=0)]): + match contact_type: + case "billing": + contact_id = org_model.billing_contact_id + case "security": + contact_id = org_model.security_contact_id + case "owner": + contact_id = org_model.owner_contact_id + case _: + raise HTTPException(status_code=422, detail="Invalid contact type") + + contact_model = (db.query(Contact).filter(Contact.id == contact_id).first()) + if contact_model is None: + raise HTTPException(status_code=404, detail="Contact not found") + + address = ContactAddress.model_validate(contact_model) + + response = OrgContactGetResponse.model_construct( + **contact_model.__dict__, + address=address + ) + + return response + + + +@router.patch("/{org_id}/contact") +async def update_contact(db: db_dependency, org_model: org_model_dependency, contact_type: Annotated[ContactType, Query()], contact_request: OrgContactPatchRequest, org_id: Annotated[int, Path(gt=0)]): + match contact_type: + case "billing": + contact_id = org_model.billing_contact_id + case "security": + contact_id = org_model.security_contact_id + case "owner": + contact_id = org_model.owner_contact_id + case _: + raise HTTPException(status_code=422, detail="Invalid contact type") + + contact_model = (db.query(Contact).filter(Contact.id == contact_id).first()) + if contact_model is None: + raise HTTPException(status_code=404, detail="Contact not found") + + update_data = contact_request.model_dump(exclude_none=True) + for key, value in update_data.items(): + if hasattr(contact_model, key): + setattr(contact_model, key, value) + else: + raise HTTPException(status_code=422, detail="Invalid keys in update request") + db.add(org_model) + db.commit() \ No newline at end of file diff --git a/src/organisation/schemas.py b/src/organisation/schemas.py index 86fe8f0..13c9689 100644 --- a/src/organisation/schemas.py +++ b/src/organisation/schemas.py @@ -7,9 +7,11 @@ Models: """ from typing import Optional +from pydantic import EmailStr, ConfigDict + from src.schemas import CustomBaseModel from src.organisation.constants import Status, ContactType - +from src.contact.schemas import ContactAddress class OrgQuestionnaire(CustomBaseModel): question_one: str @@ -21,10 +23,6 @@ class OrgOrgPostRequest(CustomBaseModel): name: str intake_questionnaire: Optional[OrgQuestionnaire] = None - billing_contact_id: Optional[int] = None - security_contact_id: Optional[int] = None - owner_contact_id: Optional[int] = None - class OrgQuestionnairePatchRequest(CustomBaseModel): intake_questionnaire: OrgQuestionnaire partial: bool @@ -33,8 +31,18 @@ class OrgStatusPatchRequest(CustomBaseModel): status: Status class OrgContactPatchRequest(CustomBaseModel): - contact_id: int - contact_type: ContactType + email: Optional[EmailStr] = None + first_name: Optional[str] = None + last_name: Optional[str] = None + phonenumber: Optional[str] = None + vat_number: Optional[str] = None + post_office_box_number: Optional[str] = None + street_address: Optional[str] = None + street_address_line_2: Optional[str] = None + locality: Optional[str] = None + address_region: Optional[str] = None + country_code: Optional[str] = None + postal_code: Optional[str] = None class OrgUserPostRequest(CustomBaseModel): user_id: int @@ -43,16 +51,20 @@ class OrgUserGetResponse(CustomBaseModel): user_id: int class OrgContactGetResponse(CustomBaseModel): - email: str - first_name: str - last_name: str - phonenumber: str + model_config = ConfigDict(from_attributes=True, extra="ignore") + + email: Optional[str] = None + first_name: Optional[str] = None + last_name: Optional[str] = None + phonenumber: Optional[str] = None vat_number: Optional[str] = None + address: ContactAddress + class OrgOrgGetResponse(CustomBaseModel): name: str status: Status root_user: Optional[str] = None - owner_contact: Optional[OrgContactGetResponse] = None - billing_contact: Optional[OrgContactGetResponse] = None - security_contact: Optional[OrgContactGetResponse] = None + owner_contact: Optional[str] = None + billing_contact: Optional[str] = None + security_contact: Optional[str] = None From b3689c8af6855e51eed6e805c7531c20dad86bc5 Mon Sep 17 00:00:00 2001 From: luxferre Date: Mon, 25 May 2026 16:54:45 +0100 Subject: [PATCH 10/10] feat: org router refactor - All TODOs done. - org_model_dependency used for all applicable routes - ORM relationships used to reduce number of queries being made and simplify endpoint code. - Missing request and response models added. - Small bug fixes --- src/organisation/router.py | 154 +++++++++++++++++------------------- src/organisation/schemas.py | 11 ++- 2 files changed, 81 insertions(+), 84 deletions(-) diff --git a/src/organisation/router.py b/src/organisation/router.py index 8d6b692..1131c40 100644 --- a/src/organisation/router.py +++ b/src/organisation/router.py @@ -12,25 +12,26 @@ Endpoints: - [delete]/{org_id} - Deletes an organisation by ID - [get]/{org_id}/contact/{contact_type} - Retrieves the contact of a specific type (owner, billing, security) for an organisation """ -from typing import Annotated +from typing import Annotated, Optional from fastapi import APIRouter, HTTPException, status from fastapi.params import Path, Query -from sqlalchemy.sql import exists - from src.contact.schemas import ContactAddress from src.database import db_dependency from src.contact.models import Contact -from src.iam.models import Group -from src.auth.service import root_user_dependency +from src.user.models import User +from src.user.exceptions import UserNotFoundException +from src.auth.service import root_user_dependency, claims_dependency from src.organisation.dependencies import org_model_dependency from src.organisation.constants import ContactType -from src.organisation.models import Organisation as Org, OrgUsers +from src.organisation.models import Organisation as Org from src.organisation.schemas import OrgOrgPostRequest, OrgQuestionnairePatchRequest, OrgStatusPatchRequest, \ OrgContactPatchRequest, \ - OrgUserPostRequest, OrgUserGetResponse, OrgContactGetResponse, OrgOrgGetResponse + OrgUserPostRequest, OrgUserGetResponse, OrgContactGetResponse, OrgOrgGetResponse, OrgRootPatchRequest, \ + OrgGroupGetResponse, OrgUserDeleteRequest + router = APIRouter( prefix="/org", @@ -39,7 +40,7 @@ router = APIRouter( @router.get("/id/{org_id}", response_model=OrgOrgGetResponse) -async def get_org_by_id(db: db_dependency, org_model: org_model_dependency, org_id: Annotated[int, Path(gt=0)]): +async def get_org_by_id(org_model: org_model_dependency, org_id: Annotated[int, Path(gt=0)]): response = { "name": org_model.name, "status": org_model.status, @@ -53,14 +54,21 @@ async def get_org_by_id(db: db_dependency, org_model: org_model_dependency, org_ @router.post("/") -async def create_org(db: db_dependency, org_request: OrgOrgPostRequest): - # TODO: Root user from current user - org_model = Org(name=org_request.name, intake_questionnaire=org_request.intake_questionnaire) +async def create_org(db: db_dependency, user: claims_dependency, org_request: OrgOrgPostRequest): + db_id: Optional[int] = user.get("db_id", None) + if db_id is None: + raise UserNotFoundException() + + org_model = Org(name=org_request.name, intake_questionnaire=org_request.intake_questionnaire.model_dump()) org_model.status = "partial" # Status is always set to partial at first, see update_questionnaire() doc db.add(org_model) db.flush() + # Adds currently logged-in user to org users list and sets them as root_user + user_model = db.get(User, db_id) + org_model.user_rel.append(user_model) + org_model.root_user_rel = user_model for contact_type in ["billing_contact_id", "security_contact_id", "owner_contact_id"]: contact_model = Contact(org_id=org_model.id) db.add(contact_model) @@ -70,139 +78,111 @@ async def create_org(db: db_dependency, org_request: OrgOrgPostRequest): @router.patch("/{org_id}/questionnaire") -async def update_questionnaire(db: db_dependency, q_request: OrgQuestionnairePatchRequest, org_id: Annotated[int, Path(gt=0)]): +async def update_questionnaire(db: db_dependency, org_model: org_model_dependency, q_request: OrgQuestionnairePatchRequest, org_id: Annotated[int, Path(gt=0)]): """ Route for updating questionnaire. The partial bool allows for submission of partially completed questionnaire and/or final "are you sure" check before setting the org to be in "submitted" status, awaiting admin approval. """ - org_model = db.query(Org).filter(Org.id == org_id).first() - if org_model is None: - raise HTTPException(status_code=404, detail="Organisation not found") - org_model.intake_questionnaire = q_request.intake_questionnaire.model_dump() # Allows for partially completed questionnaires to be saved without being submitted for review if not q_request.partial: org_model.status = "submitted" - db.add(org_model) db.commit() @router.patch("/{org_id}/status") -async def update_status(db: db_dependency, status_request: OrgStatusPatchRequest, org_id: Annotated[int, Path(gt=0)]): - org_model = db.query(Org).filter(Org.id == org_id).first() - if org_model is None: - raise HTTPException(status_code=404, detail="Organisation not found") - +async def update_status(db: db_dependency, org_model: org_model_dependency, status_request: OrgStatusPatchRequest, org_id: Annotated[int, Path(gt=0)]): org_model.status = status_request.status - db.add(org_model) db.commit() -@router.get("/{org_id}/users", response_model=list[OrgUserGetResponse]) -async def get_users(db: db_dependency, org_id: Annotated[int, Path(gt=0)]): - org_exists = db.query(exists().where(Org.id == org_id)).scalar() - if not org_exists: - raise HTTPException(status_code=404, detail="Organisation not found") - - org_user_models = db.query(OrgUsers).filter(OrgUsers.org_id == org_id).all() - - return org_user_models +@router.get("/{org_id}/users", response_model=OrgUserGetResponse) +async def get_users(org_model: org_model_dependency, org_id: Annotated[int, Path(gt=0)]): + return {"users": [user.email for user in org_model.user_rel]} @router.post("/{org_id}/users") -async def add_user_to_org(db: db_dependency, user_request: OrgUserPostRequest, org_id: Annotated[int, Path(gt=0)]): - org_model = (db.query(Org).filter(Org.id == org_id).first()) - if org_model is None: - raise HTTPException(status_code=404, detail="Organisation not found") - - org_user_model = OrgUsers(**user_request.model_dump(), org_id=org_id) - - db.add(org_user_model) +async def add_user_to_org(db: db_dependency, org_model: org_model_dependency, user_request: OrgUserPostRequest, org_id: Annotated[int, Path(gt=0)]): + user_model = db.get(User, user_request.user_id) + if user_model in org_model.user_rel: + return + org_model.user_rel.append(user_model) db.commit() -@router.delete("/{org_id}") -async def delete_organisation_by_id(db: db_dependency, org_id: Annotated[int, Path(gt=0)]): - org_model = (db.query(Org).filter(Org.id == org_id).first()) - if org_model is None: - raise HTTPException(status_code=404, detail="Organisation not found") +@router.delete("/{org_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_organisation_by_id(db: db_dependency, org_model: org_model_dependency, org_id: Annotated[int, Path(gt=0)]): db.delete(org_model) db.commit() -@router.patch("/{org_id}/root_user") -async def update_root_user(db: db_dependency, org_model: org_model_dependency, org_id: Annotated[int, Path(gt=0)], root_user: Annotated[int, Query(gt=0)]): - # TODO: Request model, ditch query - # TODO: Verify root_user exists, possibly with a user_model_dependency - org_model.root_user_id = root_user - db.add(org_model) +@router.patch("/{org_id}/root_user", status_code=status.HTTP_204_NO_CONTENT) +async def update_root_user(db: db_dependency, org_model: org_model_dependency, org_id: Annotated[int, Path(gt=0)], user_request: OrgRootPatchRequest): + root_user_model = db.get(User, user_request.user_id) + if root_user_model is None: + raise UserNotFoundException(user_id=user_request.user_id) + + org_model.root_user_rel = root_user_model db.commit() - # TODO: Response model -@router.get("/{org_id}/groups") -async def get_org_groups(db: db_dependency, org_id: Annotated[int, Path(gt=0)]): - org_group_models = db.query(Group).filter(Group.org_id == org_id).all() +@router.get("/{org_id}/groups", response_model=OrgGroupGetResponse) +async def get_org_groups(org_model: org_model_dependency, org_id: Annotated[int, Path(gt=0)]): + return {"groups": [group.name for group in org_model.group_rel]} - # TODO: Response model - return org_group_models -@router.delete("/{org_id}/user") -async def remove_user_from_org(db: db_dependency, org_model: org_model_dependency, org_id: Annotated[int, Path(gt=0)], user_id: Annotated[int, Query(gt=0)]): - orguser_model = db.query(OrgUsers).filter(OrgUsers.org_id == org_id, OrgUsers.user_id == user_id).first() +@router.delete("/{org_id}/user", status_code=status.HTTP_204_NO_CONTENT) +async def remove_user_from_org(db: db_dependency, org_model: org_model_dependency, org_id: Annotated[int, Path(gt=0)], user_request: OrgUserDeleteRequest): + user_id = user_request.user_id + user = db.get(User, user_id) - if orguser_model is None: - raise HTTPException(status_code=status.HTTP_204_NO_CONTENT) + if user is None: + raise UserNotFoundException(user_id=user_id) - db.delete(orguser_model) + if user not in org_model.user_rel: + raise HTTPException(status_code=status.HTTP_204_NOT_FOUND) + + org_model.user_rel.remove(user) db.commit() - pass - @router.get("/{org_id}/contact", response_model=OrgContactGetResponse) -async def get_contact(db: db_dependency, org_model: org_model_dependency, contact_type: Annotated[ContactType, Query()], org_id: Annotated[int, Path(gt=0)]): +async def get_contact(org_model: org_model_dependency, contact_type: Annotated[ContactType, Query()], org_id: Annotated[int, Path(gt=0)]): match contact_type: case "billing": - contact_id = org_model.billing_contact_id + contact_model = org_model.billing_contact_rel case "security": - contact_id = org_model.security_contact_id + contact_model = org_model.security_contact_rel case "owner": - contact_id = org_model.owner_contact_id + contact_model = org_model.owner_contact_rel case _: raise HTTPException(status_code=422, detail="Invalid contact type") - contact_model = (db.query(Contact).filter(Contact.id == contact_id).first()) if contact_model is None: raise HTTPException(status_code=404, detail="Contact not found") - address = ContactAddress.model_validate(contact_model) - - response = OrgContactGetResponse.model_construct( + return OrgContactGetResponse.model_construct( **contact_model.__dict__, - address=address + address=ContactAddress.model_validate(contact_model) ) - return response - -@router.patch("/{org_id}/contact") +@router.patch("/{org_id}/contact", response_model=OrgContactGetResponse) async def update_contact(db: db_dependency, org_model: org_model_dependency, contact_type: Annotated[ContactType, Query()], contact_request: OrgContactPatchRequest, org_id: Annotated[int, Path(gt=0)]): match contact_type: case "billing": - contact_id = org_model.billing_contact_id + contact_model = org_model.billing_contact_rel case "security": - contact_id = org_model.security_contact_id + contact_model = org_model.security_contact_rel case "owner": - contact_id = org_model.owner_contact_id + contact_model = org_model.owner_contact_rel case _: raise HTTPException(status_code=422, detail="Invalid contact type") - contact_model = (db.query(Contact).filter(Contact.id == contact_id).first()) if contact_model is None: raise HTTPException(status_code=404, detail="Contact not found") @@ -212,5 +192,13 @@ async def update_contact(db: db_dependency, org_model: org_model_dependency, con setattr(contact_model, key, value) else: raise HTTPException(status_code=422, detail="Invalid keys in update request") - db.add(org_model) - db.commit() \ No newline at end of file + db.flush() + + response = OrgContactGetResponse.model_construct( + **contact_model.__dict__, + address=ContactAddress.model_validate(contact_model) + ) + + db.commit() + + return response diff --git a/src/organisation/schemas.py b/src/organisation/schemas.py index 13c9689..ae7322c 100644 --- a/src/organisation/schemas.py +++ b/src/organisation/schemas.py @@ -47,9 +47,18 @@ class OrgContactPatchRequest(CustomBaseModel): class OrgUserPostRequest(CustomBaseModel): user_id: int -class OrgUserGetResponse(CustomBaseModel): +class OrgUserDeleteRequest(CustomBaseModel): user_id: int +class OrgRootPatchRequest(CustomBaseModel): + user_id: int + +class OrgUserGetResponse(CustomBaseModel): + users: list[str] + +class OrgGroupGetResponse(CustomBaseModel): + groups: list[str] + class OrgContactGetResponse(CustomBaseModel): model_config = ConfigDict(from_attributes=True, extra="ignore")