Initial commit

This commit is contained in:
Chris Milne 2026-04-06 12:41:49 +01:00
commit 376a7a9fe5
71 changed files with 2326 additions and 0 deletions

View file

@ -0,0 +1,7 @@
"""
Configurations for <this module>
Configurations:
- List: Description
- Configs: Description
"""

View file

@ -0,0 +1,7 @@
"""
Constants and error codes for <this module>
Constants:
- List: Description
- Consts: Description
"""

View file

@ -0,0 +1,11 @@
"""
Router dependencies for <this module>
Classes:
- List: Description
- Classes: Description
Functions:
- List: Description
- Functions: Description
"""

View file

@ -0,0 +1,7 @@
"""
Module specific exceptions for <this module>
Exceptions:
- List: Description
- Exceptions: Description
"""

View file

@ -0,0 +1,7 @@
"""
Database models for <this module>
Models:
- List: Description
- Models: Description
"""

View file

@ -0,0 +1,13 @@
"""
Router endpoints for <this module>
Endpoints:
- List: Description
- Endpoints: Description
"""
from fastapi import APIRouter
_router = APIRouter(
tags=[""],
)

View file

@ -0,0 +1,7 @@
"""
Pydantic models for <this module>
Models:
- List: Description
- Models: Description
"""

View file

@ -0,0 +1,11 @@
"""
Module specific business logic for <this module>
Classes:
- List: Description
- Classes: Description
Functions:
- List: Description
- Functions: Description
"""

View file

@ -0,0 +1,11 @@
"""
Non-business logic reusable functions and classes for <this module>
Classes:
- List: Description
- Classes: Description
Functions:
- List: Description
- Functions: Description
"""

7
src/admin/config.py Normal file
View file

@ -0,0 +1,7 @@
"""
Configurations for the admin module
Configurations:
- List: Description
- Configs: Description
"""

7
src/admin/constants.py Normal file
View file

@ -0,0 +1,7 @@
"""
Constants and error codes for the admin module
Constants:
- List: Description
- Consts: Description
"""

11
src/admin/dependencies.py Normal file
View file

@ -0,0 +1,11 @@
"""
Router dependencies for the admin module
Classes:
- List: Description
- Classes: Description
Functions:
- List: Description
- Functions: Description
"""

7
src/admin/exceptions.py Normal file
View file

@ -0,0 +1,7 @@
"""
Module specific exceptions for the admin module
Exceptions:
- List: Description
- Exceptions: Description
"""

7
src/admin/models.py Normal file
View file

@ -0,0 +1,7 @@
"""
Database models for the admin module
Models:
- List: Description
- Models: Description
"""

50
src/admin/router.py Normal file
View file

@ -0,0 +1,50 @@
"""
Router endpoints for the admin module
Endpoints:
- List: Description
- Endpoints: Description
"""
from fastapi import APIRouter, HTTPException
from fastapi.params import Path
from sqlalchemy.sql import exists
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.user.models import User
from src.user.schemas import UserResponse, OIDCUser, OrgResponse
from src.organisation.models import OrgUsers, Organisation
from src.auth.service import claims_dependency, org_user_dependency, org_admin_dependency
from src.database import db_dependency
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_org_admin: org_user_dependency, contact_type: ContactType, org_id: 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

7
src/admin/schemas.py Normal file
View file

@ -0,0 +1,7 @@
"""
Pydantic models for the admin module
Models:
- List: Description
- Models: Description
"""

11
src/admin/service.py Normal file
View file

@ -0,0 +1,11 @@
"""
Module specific business logic for the admin module
Classes:
- List: Description
- Classes: Description
Functions:
- List: Description
- Functions: Description
"""

11
src/admin/utils.py Normal file
View file

@ -0,0 +1,11 @@
"""
Non-business logic reusable functions and classes for the admin module
Classes:
- List: Description
- Classes: Description
Functions:
- List: Description
- Functions: Description
"""

25
src/api.py Normal file
View file

@ -0,0 +1,25 @@
"""
This module hooks the routers for the main endpoints into a single router for importing to the app.
"""
from fastapi import APIRouter
from src.auth.router import router as auth_router
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
api_router = APIRouter()
api_router.include_router(auth_router)
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.get("/healthcheck", include_in_schema=False)
def healthcheck():
"""Simple healthcheck endpoint."""
return {"status": "ok"}

17
src/auth/config.py Normal file
View file

@ -0,0 +1,17 @@
"""
Configurations for auth module, import auth_settings
Configurations:
- List: Description
- Configs: Description
"""
from src.config import CustomBaseSettings
class AuthConfig(CustomBaseSettings):
OIDC_CONFIG: str = ""
OIDC_ISSUER: str = ""
OIDC_AUDIENCE: str = ""
CLIENT_ID: str = ""
auth_settings = AuthConfig()

7
src/auth/constants.py Normal file
View file

@ -0,0 +1,7 @@
"""
Constants and error codes for auth module
Constants:
- List: Description
- Consts: Description
"""

11
src/auth/dependencies.py Normal file
View file

@ -0,0 +1,11 @@
"""
Router dependencies for auth module
Classes:
- List: Description
- Classes: Description
Functions:
- List: Description
- Functions: Description
"""

7
src/auth/exceptions.py Normal file
View file

@ -0,0 +1,7 @@
"""
Module specific exceptions for auth module
Exceptions:
- List: Description
- Exceptions: Description
"""

7
src/auth/models.py Normal file
View file

@ -0,0 +1,7 @@
"""
Database models for auth module
Models:
- List: Description
- Models: Description
"""

61
src/auth/router.py Normal file
View file

@ -0,0 +1,61 @@
"""
Router endpoints for auth module
Contains oauth registration
Endpoints:
"""
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='/')

7
src/auth/schemas.py Normal file
View file

@ -0,0 +1,7 @@
"""
Pydantic models for auth module
Models:
- List: Description
- Models: Description
"""

225
src/auth/service.py Normal file
View file

@ -0,0 +1,225 @@
"""
Module specific business logic for auth module
Exports:
- claims_dependency
"""
import json
from typing import Annotated
from authlib.jose import jwt
from urllib.request import urlopen
from fastapi import Depends, HTTPException, Path
from fastapi.security import OpenIdConnect
from authlib.jose.rfc7517.jwk import JsonWebKey
from authlib.jose.rfc7517.key_set import KeySet
from authlib.oauth2.rfc7523.validator import JWTBearerToken
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.database import db_dependency
oidc = OpenIdConnect(openIdConnectUrl=auth_settings.OIDC_CONFIG)
oidc_dependency = Annotated[str, Depends(oidc)]
async def get_current_user(oidc_auth_string: oidc_dependency) -> JWTBearerToken:
config_url = urlopen(auth_settings.OIDC_CONFIG)
config = json.loads(config_url.read())
jwks_uri = config["jwks_uri"]
key_response = urlopen(jwks_uri)
jwk_keys: KeySet = JsonWebKey.import_key_set(json.loads(key_response.read()))
claims_options = {
"exp": {"essential": True},
"aud": {"essential": True, "value": "account"},
"iss": {"essential": True, "value": auth_settings.OIDC_ISSUER},
}
claims: JWTBearerToken = jwt.decode(
oidc_auth_string.replace("Bearer ", ""),
jwk_keys,
claims_options=claims_options,
claims_cls=JWTBearerToken,
)
claims.validate()
db_id = await add_user_to_db(claims)
claims["db_id"] = db_id
return claims
claims_dependency = Annotated[JWTBearerToken, Depends(get_current_user)]
async def is_org_user(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
).exists()
)
org_user_exists = db.query(exists_query).scalar()
if not org_user_exists:
raise HTTPException(status_code=401, detail="Not authorised")
return org_user_exists
org_user_dependency = Annotated[JWTBearerToken, 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[JWTBearerToken, Depends(is_org_admin)]
async def is_super_admin(claims: claims_dependency):
super_admin_ids = []
db_id = claims.get("db_id", None)
if db_id is None:
raise HTTPException(status_code=404, detail="User not found in db")
if db_id not in super_admin_ids:
raise HTTPException(status_code=401, detail="Not authorised")
return True
super_admin_dependency = Annotated[JWTBearerToken, Depends(is_super_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"])

11
src/auth/utils.py Normal file
View file

@ -0,0 +1,11 @@
"""
Non-business logic reusable functions and classes for auth module
Classes:
- List: Description
- Classes: Description
Functions:
- List: Description
- Functions: Description
"""

53
src/config.py Normal file
View file

@ -0,0 +1,53 @@
"""
Global configurations: import settings, app_configs
Classes:
- CustomBaseSettings - Base class to be used by all modules for loading configs
"""
from typing import Any
from urllib import parse
from pydantic_settings import BaseSettings, SettingsConfigDict
from pydantic import SecretStr
from src.constants import Environment
class CustomBaseSettings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env", env_file_encoding="utf-8", extra="ignore"
)
class Config(CustomBaseSettings):
APP_VERSION: str = "0.1"
ENVIRONMENT: Environment = Environment.PRODUCTION
SECRET_KEY: SecretStr = ""
CORS_ORIGINS: list[str] = ["*"]
CORS_ORIGINS_REGEX: str | None = None
CORS_HEADERS: list[str] = ["*"]
DATABASE_NAME: str = "fastapi-exp"
DATABASE_PORT: str = "5432"
DATABASE_HOSTNAME: str = "localhost"
DATABASE_CREDENTIALS: SecretStr = ""
settings = Config()
DATABASE_NAME = settings.DATABASE_NAME
DATABASE_PORT = settings.DATABASE_PORT
DATABASE_HOSTNAME = settings.DATABASE_HOSTNAME
DATABASE_CREDENTIALS = settings.DATABASE_CREDENTIALS.get_secret_value()
# this will support special chars for credentials
_DATABASE_CREDENTIAL_USER, _DATABASE_CREDENTIAL_PASSWORD = str(DATABASE_CREDENTIALS).split(":")
_QUOTED_DATABASE_PASSWORD = parse.quote_plus(str(_DATABASE_CREDENTIAL_PASSWORD))
SQLALCHEMY_DATABASE_URI = SecretStr(f"postgresql+psycopg://{_DATABASE_CREDENTIAL_USER}:{_QUOTED_DATABASE_PASSWORD}@{DATABASE_HOSTNAME}:{DATABASE_PORT}/{DATABASE_NAME}")
app_configs: dict[str, Any] = {"title": "App API"}
if settings.ENVIRONMENT.is_deployed:
app_configs["root_path"] = f"/v{settings.APP_VERSION}"
if not settings.ENVIRONMENT.is_debug:
app_configs["openapi_url"] = None # hide docs

36
src/constants.py Normal file
View file

@ -0,0 +1,36 @@
"""
Global constants
Classes:
- Environment(StrEnum): LOCAL, TESTING, STAGING, PRODUCTION
"""
from enum import StrEnum, auto
class Environment(StrEnum):
"""
Enumeration of environments.
Attributes:
LOCAL (str): Application is running locally
TESTING (str): Application is running in testing mode
STAGING (str): Application is running in staging mode (ie not testing)
PRODUCTION (str): Application is running in production mode
"""
LOCAL = auto()
TESTING = auto()
STAGING = auto()
PRODUCTION = auto()
@property
def is_debug(self):
return self in (self.LOCAL, self.STAGING, self.TESTING)
@property
def is_testing(self):
return self == self.TESTING
@property
def is_deployed(self) -> bool:
return self in (self.STAGING, self.PRODUCTION)

7
src/contact/config.py Normal file
View file

@ -0,0 +1,7 @@
"""
Configurations for contact module
Configurations:
- List: Description
- Configs: Description
"""

7
src/contact/constants.py Normal file
View file

@ -0,0 +1,7 @@
"""
Constants and error codes for contact module
Constants:
- List: Description
- Consts: Description
"""

View file

@ -0,0 +1,11 @@
"""
Router dependencies for contact module
Classes:
- List: Description
- Classes: Description
Functions:
- List: Description
- Functions: Description
"""

View file

@ -0,0 +1,7 @@
"""
Module specific exceptions for contact module
Exceptions:
- List: Description
- Exceptions: Description
"""

29
src/contact/models.py Normal file
View file

@ -0,0 +1,29 @@
"""
Database models for contact module
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 src.database import Base
class Contact(Base):
__tablename__ = "contact"
id = Column(Integer, primary_key=True)
email = Column(String)
first_name = Column(String)
last_name = Column(String)
phonenumber = Column(String)
vat_number = Column(String, default=None, nullable=True)
street_address = Column(String)
street_address_line_2 = Column(String)
post_office_box_number = Column(String, default=None, nullable=True)
locality = Column(String) # Ie City
country_code = Column(String) # Eg GB
address_region = Column(String, default=None, nullable=True)
postal_code = Column(String)

116
src/contact/router.py Normal file
View file

@ -0,0 +1,116 @@
"""
Router endpoints for contact module
Endpoints:
- [get]/{contact_id} - Returns non-address type details for contact
- [get]/{contact_id}/address - Returns address details for contact
- [get]/{contact_id}/orgs - Returns a list of orgs which the contact is assigned to, and what they are assigned as
- [post]/ - Creates a new contact
- [patch]/{contact_id} - Updates the details of an existing contact
- [delete]/{contact_id} - Deletes a contact by ID
"""
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: 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: 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: 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

62
src/contact/schemas.py Normal file
View file

@ -0,0 +1,62 @@
"""
Pydantic models for contact module
Models:
- List: Description
- Models: Description
"""
from typing import Optional
from pydantic import Field, EmailStr
from src.organisation.constants import ContactType
from src.schemas import CustomBaseModel
class ContactContactGetResponse(CustomBaseModel):
email: str
first_name: str
last_name: str
phonenumber: str
vat_number: Optional[str] = None
class ContactAddressGetResponse(CustomBaseModel):
post_office_box_number: Optional[str] = None
street_address: Optional[str] = None # If using a PO box, there would be no street address
street_address_line_2: Optional[str] = None
locality: str
address_region: Optional[str] = None
country_code: str
postal_code: str
class ContactContactPostRequest(CustomBaseModel):
email: EmailStr
first_name: str
last_name: str
phonenumber: str
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: str
address_region: Optional[str] = None
country_code: str
postal_code: str
class ContactUpdateRequest(CustomBaseModel):
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 ContactOrgGetResponse(CustomBaseModel):
name: str
contact_types: list[ContactType]

11
src/contact/service.py Normal file
View file

@ -0,0 +1,11 @@
"""
Module specific business logic for contact module
Classes:
- List: Description
- Classes: Description
Functions:
- List: Description
- Functions: Description
"""

11
src/contact/utils.py Normal file
View file

@ -0,0 +1,11 @@
"""
Non-business logic reusable functions and classes for contact module
Classes:
- List: Description
- Classes: Description
Functions:
- List: Description
- Functions: Description
"""

30
src/database.py Normal file
View file

@ -0,0 +1,30 @@
"""
Database connections and init
Exports:
- db_dependency
- Base (sqlalchemy base model)
"""
from typing import Annotated
from sqlalchemy import create_engine
from sqlalchemy.orm import declarative_base, sessionmaker, Session
from fastapi import Depends
from src.config import SQLALCHEMY_DATABASE_URI
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_dependency = Annotated[Session, Depends(get_db)]
Base = declarative_base()

3
src/exceptions.py Normal file
View file

@ -0,0 +1,3 @@
"""
Global exceptions
"""

47
src/main.py Normal file
View file

@ -0,0 +1,47 @@
"""
Application root file: Inits the FastAPI application
"""
from contextlib import asynccontextmanager
from typing import AsyncGenerator
from fastapi import FastAPI
from starlette.middleware.sessions import SessionMiddleware
from starlette.middleware.cors import CORSMiddleware
from src.config import settings
from src.api import api_router
from src.auth.config import auth_settings
@asynccontextmanager
async def lifespan(_application: FastAPI) -> AsyncGenerator:
# Startup
yield
# Shutdown
if settings.ENVIRONMENT.is_deployed:
# Do this only on prod
pass
app = FastAPI(
swagger_ui_init_oauth={
"clientId": auth_settings.CLIENT_ID,
"usePkceWithAuthorizationCodeGrant": True,
"scopes": "openid profile email",
}
)
app.add_middleware(SessionMiddleware, secret_key=settings.SECRET_KEY.get_secret_value())
app.add_middleware(
CORSMiddleware,
allow_origins=settings.CORS_ORIGINS,
allow_origin_regex=settings.CORS_ORIGINS_REGEX,
allow_credentials=True,
allow_methods=("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"),
allow_headers=settings.CORS_HEADERS,
)
app.include_router(api_router)

4
src/models.py Normal file
View file

@ -0,0 +1,4 @@
"""
Global database models
"""

View file

@ -0,0 +1,7 @@
"""
Configurations for organisation module
Configurations:
- List: Description
- Configs: Description
"""

View file

@ -0,0 +1,44 @@
"""
Constants and error codes for organisation module
Classes:
- Status(StrEnum): PARTIAL, SUBMITTED, REMEDIATION, APPROVED, REJECTED, REMOVED
- ContactType(StrEnum): BILLING, SECURITY, OWNER
"""
from enum import StrEnum, auto
class Status(StrEnum):
"""
Enumeration of organisation statuses.
Attributes:
PARTIAL(str): Organisation has been created but questionnaire hasn't been submitted.
SUBMITTED (str): Questionnaire submitted but not approved.
REMEDIATION (str): Questionnaire submitted but requires revisions.
APPROVED (str): Questionnaire has been approved by an admin.
REJECTED (str): Questionnaire has been rejected by an admin.
REMOVED (str): Organisation has been removed.
"""
PARTIAL = auto()
SUBMITTED = auto()
REMEDIATION = auto()
APPROVED = auto()
REJECTED = auto()
REMOVED = auto()
class ContactType(StrEnum):
"""
Enumeration of organisation contact types.
Attributes:
BILLING(str): Billing contact.
SECURITY (str): Security contact.
OWNER (str): Owner contact.
"""
BILLING = auto()
SECURITY = auto()
OWNER = auto()

View file

@ -0,0 +1,11 @@
"""
Router dependencies for organisation module
Classes:
- List: Description
- Classes: Description
Functions:
- List: Description
- Functions: Description
"""

View file

@ -0,0 +1,7 @@
"""
Module specific exceptions for organisation module
Exceptions:
- List: Description
- Exceptions: Description
"""

View file

@ -0,0 +1,34 @@
"""
Database models for organisation module
Models:
- Organisation: id[pk], name, status, intake_questionnaire,
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 src.database import Base
from src.contact.models import Contact
from src.user.models import User
class Organisation(Base):
__tablename__ = "organisation"
id = Column(Integer, primary_key=True)
name = Column(String)
status = Column(String, default="partial")
intake_questionnaire = Column(JSON)
billing_contact_id = Column(Integer, ForeignKey("contact.id"))
security_contact_id = Column(Integer, ForeignKey("contact.id"))
owner_contact_id = Column(Integer, ForeignKey("contact.id"))
class OrgUsers(Base):
__tablename__ = "orgusers"
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())

194
src/organisation/router.py Normal file
View file

@ -0,0 +1,194 @@
"""
Router endpoints for organisation module
Endpoints:
- [get]/id/{org_id} - Retrieves an organisation by its ID
- [post]/ - Creates a new organisation
- [patch]/{org_id}/questionnaire - Updates the questionnaire data for an organisation (can be partial or final submission)
- [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 fastapi import APIRouter, HTTPException
from fastapi.params import Path
from sqlalchemy.sql import exists
from src.auth.service import super_admin_dependency
from src.database import db_dependency
from src.contact.models import Contact
from src.organisation.constants import ContactType
from src.organisation.models import Organisation as Org, OrgUsers
from src.organisation.schemas import OrgOrgPostRequest, OrgQuestionnairePatchRequest, OrgStatusPatchRequest, \
OrgContactPatchRequest, \
OrgUserPostRequest, OrgUserGetResponse, OrgContactGetResponse, OrgOrgGetResponse
router = APIRouter(
prefix="/org",
tags=["org"],
)
@router.get("/id/{org_id}", response_model=OrgOrgGetResponse)
async def get_org_by_id(db: db_dependency, org_id: 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")
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()),
}
return response
@router.post("/")
async def create_org(db: db_dependency, org_request: OrgOrgPostRequest):
org_model = Org(**org_request.model_dump())
org_model.status = "partial" # Status is always set to partial at first, see update_questionnaire() doc
db.add(org_model)
db.commit()
@router.patch("/{org_id}/questionnaire")
async def update_questionnaire(db: db_dependency, q_request: OrgQuestionnairePatchRequest, org_id: 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
# 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: 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_model.status = status_request.status
db.add(org_model)
db.commit()
@router.patch("/{org_id}/contact")
async def update_contact(db: db_dependency, contact_request: OrgContactPatchRequest, org_id: 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: 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/admins", response_model=list[OrgUserGetResponse])
async def get_admin_users(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")
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: int = Path(gt=0)):
org_user_model = OrgUsers(**user_request.model_dump(), org_id=org_id)
db.add(org_user_model)
db.commit()
@router.patch("/{org_id}/users")
async def update_user_details(db: db_dependency, user_request: OrgUserPostRequest, org_id: int = Path(gt=0)):
"""
Currently used only to update user admin status for organisation.
"""
# TODO: Check if org exists
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: 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")
db.delete(org_model)
db.commit()
@router.get("/{org_id}/contact/{contact_type}", response_model=OrgContactGetResponse)
async def get_contact(db: db_dependency, contact_type: ContactType, org_id: 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

View file

@ -0,0 +1,54 @@
"""
Pydantic models for organisation module
Models:
- List: Description
- Models: Description
"""
from typing import Optional
from pydantic import Json
from src.schemas import CustomBaseModel
from src.organisation.constants import Status, ContactType
class OrgOrgPostRequest(CustomBaseModel):
name: str
intake_questionnaire: Optional[Json] = None
billing_contact_id: Optional[int] = None
security_contact_id: Optional[int] = None
owner_contact_id: Optional[int] = None
class OrgQuestionnairePatchRequest(CustomBaseModel):
intake_questionnaire: Json
partial: bool
class OrgStatusPatchRequest(CustomBaseModel):
status: Status
class OrgContactPatchRequest(CustomBaseModel):
contact_id: int
contact_type: ContactType
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
first_name: str
last_name: str
phonenumber: str
vat_number: Optional[str] = None
class OrgOrgGetResponse(CustomBaseModel):
name: str
status: Status
owner_contact: OrgContactGetResponse
billing_contact: OrgContactGetResponse
security_contact: OrgContactGetResponse

View file

@ -0,0 +1,11 @@
"""
Module specific business logic for organisation module
Classes:
- List: Description
- Classes: Description
Functions:
- List: Description
- Functions: Description
"""

11
src/organisation/utils.py Normal file
View file

@ -0,0 +1,11 @@
"""
Non-business logic reusable functions and classes for organisation module
Classes:
- List: Description
- Classes: Description
Functions:
- List: Description
- Functions: Description
"""

5
src/schemas.py Normal file
View file

@ -0,0 +1,5 @@
from pydantic import BaseModel
class CustomBaseModel(BaseModel):
pass

7
src/user/config.py Normal file
View file

@ -0,0 +1,7 @@
"""
Configurations for user module
Configurations:
- List: Description
- Configs: Description
"""

7
src/user/constants.py Normal file
View file

@ -0,0 +1,7 @@
"""
Constants and error codes for user module
Constants:
- List: Description
- Consts: Description
"""

11
src/user/dependencies.py Normal file
View file

@ -0,0 +1,11 @@
"""
Router dependencies for user module
Classes:
- List: Description
- Classes: Description
Functions:
- List: Description
- Functions: Description
"""

7
src/user/exceptions.py Normal file
View file

@ -0,0 +1,7 @@
"""
Module specific exceptions for user module
Exceptions:
- List: Description
- Exceptions: Description
"""

19
src/user/models.py Normal file
View file

@ -0,0 +1,19 @@
"""
Database models for user module
Models:
- User - id[pk], email, first_name, last_name, oidc_id
"""
from sqlalchemy import Column, Integer, String
from src.database import Base
class User(Base):
__tablename__ = "user"
id = Column(Integer, primary_key=True)
email = Column(String)
first_name = Column(String)
last_name = Column(String)
oidc_id = Column(String, index=True, unique=True)

133
src/user/router.py Normal file
View file

@ -0,0 +1,133 @@
"""
Router endpoints for user module
Endpoints:
- [get]/me/claims - Retrieves user's OIDC claims
- [get]/me/db - Retrieves the user data from the db that corresponds to the current OIDC user
- [get]/me/orgs - Retrieves all organisations associated with the current user
- [get]/me/orgs/admin - Retrieves only admin organisations for the current user
- [get]/{user_id} - Retrieves a specific user by their ID
- [get]/{user_id}/orgs - Retrieves all organisations associated with a specific user
- [get]/{user_id}/orgs/admin - Retrieves only admin organisations for a specific user
- [delete]/{user_id} - Deletes a user from the db by their db ID
"""
from fastapi import APIRouter, HTTPException
from fastapi.params import Path
from sqlalchemy.sql import exists
from src.user.models import User
from src.user.schemas import UserResponse, OIDCUser, OrgResponse
from src.organisation.models import OrgUsers, Organisation
from src.auth.service import claims_dependency
from src.database import db_dependency
router = APIRouter(
prefix="/user",
tags=["user"],
)
@router.get("/me/claims")
async def current_user_claims(user: claims_dependency):
return user
@router.get("/me/db", response_model=OIDCUser)
async def current_user(user: claims_dependency, db: db_dependency):
db_id = user.get("db_id", None)
if db_id is None:
raise HTTPException(status_code=404, detail="User not found in db")
user_model = (db.query(User).filter(User.id == db_id).first())
if user_model is None:
raise HTTPException(status_code=404, detail="User not found")
return user_model
@router.get("/me/orgs", response_model=list[OrgResponse])
async def get_current_organisations(db: db_dependency, user: claims_dependency):
user_id = user.get("db_id", None)
if user_id is None:
raise HTTPException(status_code=404, detail="User not found")
user_exists = db.query(exists().where(User.id == user_id)).scalar()
if not user_exists:
raise HTTPException(status_code=404, detail="User not found")
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)
.all()
)
return org_user_models
@router.get("/me/orgs/admin", response_model=list[OrgResponse])
async def get_current_admin_organisations(db: db_dependency, user: claims_dependency):
user_id = user.get("db_id", None)
if user_id is None:
raise HTTPException(status_code=404, detail="User not found")
user_exists = db.query(exists().where(User.id == user_id)).scalar()
if not user_exists:
raise HTTPException(status_code=404, detail="User not found")
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)
async def get_user_by_id(user_id: int, db: db_dependency):
user_model = (db.query(User).filter(User.id == user_id).first())
if user_model is None:
raise HTTPException(status_code=404, detail="User not found")
return user_model
@router.get("/{user_id}/orgs", response_model=list[OrgResponse])
async def get_organisations(db: db_dependency, user_id: int = Path(gt=0)):
user_exists = db.query(exists().where(User.id == user_id)).scalar()
if not user_exists:
raise HTTPException(status_code=404, detail="User not found")
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)
.all()
)
return org_user_models
@router.get("/{user_id}/orgs/admin", response_model=list[OrgResponse])
async def get_admin_organisations(db: db_dependency, user_id: int = Path(gt=0)):
user_exists = db.query(exists().where(User.id == user_id)).scalar()
if not user_exists:
raise HTTPException(status_code=404, detail="User not found")
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}")
async def delete_user_by_id(user_id: int, db: db_dependency):
user_model = (db.query(User).filter(User.id == user_id).first())
if user_model is None:
raise HTTPException(status_code=404, detail="User not found")
db.delete(user_model)
db.commit()

28
src/user/schemas.py Normal file
View file

@ -0,0 +1,28 @@
"""
Pydantic models for user module
Models:
- List: Description
- Models: Description
"""
from src.schemas import CustomBaseModel
from pydantic import Field
class OIDCUser(CustomBaseModel):
first_name: str
last_name: str
email: str
oidc_id: str
class UserResponse(CustomBaseModel):
first_name: str
last_name: str
email: str
class OrgResponse(CustomBaseModel):
org_id: int
name: str
is_admin: bool

35
src/user/service.py Normal file
View file

@ -0,0 +1,35 @@
"""
Module specific business logic for user module
Functions:
- add_user_to_db
Exports:
- add_user_to_db
"""
from authlib.jose import JWTClaims
from fastapi import HTTPException
from src.user.schemas import OIDCUser
from src.user.models import User
from src.database import get_db
async def add_user_to_db(user_claims: JWTClaims) -> int:
try:
valid_user = OIDCUser(first_name=user_claims["given_name"], last_name=user_claims["family_name"], email=user_claims["email"], oidc_id=user_claims["sub"])
except Exception as e:
print(e)
raise HTTPException(status_code=422, detail="Invalid or missing OIDC data")
db = next(get_db())
db_user = db.query(User).filter(User.oidc_id == valid_user.oidc_id).first()
if not db_user:
user_model = User(**valid_user.model_dump())
db.add(user_model)
db.commit()
return user_model.id
else:
# Verify details still match and update accordingly.
return db_user.id

11
src/user/utils.py Normal file
View file

@ -0,0 +1,11 @@
"""
Non-business logic reusable functions and classes for user module
Classes:
- List: Description
- Classes: Description
Functions:
- List: Description
- Functions: Description
"""