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

1
.alembic/README Normal file
View file

@ -0,0 +1 @@
Generic single-database configuration.

80
.alembic/env.py Normal file
View file

@ -0,0 +1,80 @@
from logging.config import fileConfig
from sqlalchemy import create_engine
from sqlalchemy import pool
from alembic import context
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.database import Base
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
target_metadata = Base.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
connectable = create_engine(SQLALCHEMY_DATABASE_URI.get_secret_value())
with connectable.connect() as connection:
context.configure(
connection=connection, target_metadata=target_metadata
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

28
.alembic/script.py.mako Normal file
View file

@ -0,0 +1,28 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
"""Upgrade schema."""
${upgrades if upgrades else "pass"}
def downgrade() -> None:
"""Downgrade schema."""
${downgrades if downgrades else "pass"}

View file

@ -0,0 +1,81 @@
"""initial database model
Revision ID: 8fe51426321d
Revises:
Create Date: 2026-04-06 12:36:46.877760
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '8fe51426321d'
down_revision: Union[str, Sequence[str], None] = None
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('contact',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('email', sa.String(), nullable=True),
sa.Column('first_name', sa.String(), nullable=True),
sa.Column('last_name', sa.String(), nullable=True),
sa.Column('phonenumber', sa.String(), nullable=True),
sa.Column('vat_number', sa.String(), nullable=True),
sa.Column('street_address', sa.String(), nullable=True),
sa.Column('street_address_line_2', sa.String(), nullable=True),
sa.Column('post_office_box_number', sa.String(), nullable=True),
sa.Column('locality', sa.String(), nullable=True),
sa.Column('country_code', sa.String(), nullable=True),
sa.Column('address_region', sa.String(), nullable=True),
sa.Column('postal_code', sa.String(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_table('user',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('email', sa.String(), nullable=True),
sa.Column('first_name', sa.String(), nullable=True),
sa.Column('last_name', sa.String(), nullable=True),
sa.Column('oidc_id', sa.String(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_user_oidc_id'), 'user', ['oidc_id'], unique=True)
op.create_table('organisation',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(), nullable=True),
sa.Column('status', sa.String(), nullable=True),
sa.Column('intake_questionnaire', sa.JSON(), nullable=True),
sa.Column('billing_contact_id', sa.Integer(), nullable=True),
sa.Column('security_contact_id', sa.Integer(), nullable=True),
sa.Column('owner_contact_id', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['billing_contact_id'], ['contact.id'], ),
sa.ForeignKeyConstraint(['owner_contact_id'], ['contact.id'], ),
sa.ForeignKeyConstraint(['security_contact_id'], ['contact.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('orgusers',
sa.Column('org_id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('is_admin', sa.Boolean(), server_default=sa.text('false'), nullable=False),
sa.ForeignKeyConstraint(['org_id'], ['organisation.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('org_id', 'user_id')
)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('orgusers')
op.drop_table('organisation')
op.drop_index(op.f('ix_user_oidc_id'), table_name='user')
op.drop_table('user')
op.drop_table('contact')
# ### end Alembic commands ###

207
.gitignore vendored Normal file
View file

@ -0,0 +1,207 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[codz]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py.cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# UV
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
#uv.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
#poetry.toml
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
#pdm.lock
#pdm.toml
.pdm-python
.pdm-build/
# pixi
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
#pixi.lock
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
# in the .venv directory. It is recommended not to include this directory in version control.
.pixi
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.envrc
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
.idea/
# Abstra
# Abstra is an AI-powered process automation framework.
# Ignore directories containing user credentials, local state, and settings.
# Learn more at https://abstra.io/docs
.abstra/
# Visual Studio Code
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
# and can be added to the global gitignore or merged into this file. However, if you prefer,
# you could uncomment the following to ignore the entire vscode folder
# .vscode/
# Ruff stuff:
.ruff_cache/
# PyPI configuration file
.pypirc
# Cursor
# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to
# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
# refer to https://docs.cursor.com/context/ignore-files
.cursorignore
.cursorindexingignore
# Marimo
marimo/_static/
marimo/_lsp/
__marimo__/

149
alembic.ini Normal file
View file

@ -0,0 +1,149 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts.
# this is typically a path given in POSIX (e.g. forward slashes)
# format, relative to the token %(here)s which refers to the location of this
# ini file
script_location = %(here)s/.alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# Or organize into date-based subdirectories (requires recursive_version_locations = true)
file_template = %%(year)d-%%(month).2d-%%(day).2d_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory. for multiple paths, the path separator
# is defined by "path_separator" below.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the tzdata library which can be installed by adding
# `alembic[tz]` to the pip requirements.
# string value is passed to ZoneInfo()
# leave blank for localtime
# timezone =
# max length of characters to apply to the "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to <script_location>/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "path_separator"
# below.
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
# path_separator; This indicates what character is used to split lists of file
# paths, including version_locations and prepend_sys_path within configparser
# files such as alembic.ini.
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
# to provide os-dependent path splitting.
#
# Note that in order to support legacy alembic.ini files, this default does NOT
# take place if path_separator is not present in alembic.ini. If this
# option is omitted entirely, fallback logic is as follows:
#
# 1. Parsing of the version_locations option falls back to using the legacy
# "version_path_separator" key, which if absent then falls back to the legacy
# behavior of splitting on spaces and/or commas.
# 2. Parsing of the prepend_sys_path option falls back to the legacy
# behavior of splitting on spaces, commas, or colons.
#
# Valid values for path_separator are:
#
# path_separator = :
# path_separator = ;
# path_separator = space
# path_separator = newline
#
# Use os.pathsep. Default configuration used for new projects.
path_separator = os
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
# database URL. This is consumed by the user-maintained env.py script only.
# other means of configuring database URLs may be customized within the env.py
# file.
sqlalchemy.url = none
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
# hooks = ruff
# ruff.type = module
# ruff.module = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Alternatively, use the exec runner to execute a binary found on your PATH
# hooks = ruff
# ruff.type = exec
# ruff.executable = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Logging configuration. This is also consumed by the user-maintained
# env.py script only.
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

20
requirements.txt Normal file
View file

@ -0,0 +1,20 @@
fastapi
pydantic
uvicorn
jinja2
python-dotenv
requests
itsdangerous
starlette
pydantic-settings
authlib
httpx
types-authlib
python-jose
pytest
uvloop; sys_platform != 'win32'
sqlalchemy
httptools
psycopg
email-validator
alembic

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

6
tests/test_main.http Normal file
View file

@ -0,0 +1,6 @@
# Test your FastAPI endpoints
GET http://127.0.0.1:8000/
Accept: application/json
###

107
uml.dbml Normal file
View file

@ -0,0 +1,107 @@
// Use DBML to define your database structure
// Docs: https://dbml.dbdiagram.io/docs
Table users {
id integer [pk]
email varchar
first_name varchar
last_name varchar
oidc_id varchar
indexes {
id
}
}
Table orgusers {
org_id integer
user_id integer
is_admin bool
indexes {
(org_id, user_id) [pk]
}
}
Table organisations {
id integer [pk]
name varchar
status varchar
intake_questionaire json
billing_contact_id integer
security_contact_id integer
owner_contact_id integer
indexes {
id
}
}
Table contacts {
id integer [pk]
email varchar
first_name varchar
last_name varchar
phonenumber varchar
vat_number varchar
address varchar
city varchar
country varchar
postcode varchar
indexes {
id
}
}
Table products {
id integer [pk]
name varchar
price float
org_id integer
indexes {
id
}
}
Table projects {
id integer [pk]
name varchar
max_billable float
end_date timestamp
org_id integer
indexes {
id
}
}
Table projectproductusage {
id integer [pk]
project_id integer
product_id integer
price float
start_time timestamp
end_time timestamp
invoice varchar
indexes {
id
(project_id, product_id)
}
}
Ref: organisations.billing_contact_id - contacts.id
Ref: organisations.security_contact_id - contacts.id
Ref: organisations.owner_contact_id - contacts.id
Ref: orgusers.org_id <> organisations.id
Ref: orgusers.user_id <> users.id
Ref: products.org_id > organisations.id
Ref: projects.org_id > organisations.id
Ref: projectproductusage.product_id <> products.id
Ref: projectproductusage.project_id <> projects.id